diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index f878c77..d5dd499 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -205,12 +205,13 @@ class TournamentsController < ApplicationController private def timer_params - { - timestamp: params[:timestamp], - timer_mode: params[:timer_mode], + timer_params = { timer_reason: params[:timer_reason], timer_reason_text: params[:timer_reason_text] } + timer_params[:timestamp] = params[:timestamp] if params.key?(:timestamp) + timer_params[:timer_mode] = params[:timer_mode] if params.key?(:timer_mode) + timer_params end def serialize_timer_state(tournament) @@ -371,6 +372,9 @@ class TournamentsController < ApplicationController timestamp = params[:timestamp] timestamp_seconds = params[:timestamp_seconds] timer_mode = params[:timer_mode] + timer_reason_param_present = params.key?(:timer_reason) + timer_reason_text_param_present = params.key?(:timer_reason_text) + timer_reason_update_requested = timer_reason_param_present || timer_reason_text_param_present normalize_timer_reason_params timer_reason = params[:timer_reason] timer_reason_text = params[:timer_reason_text] @@ -384,7 +388,12 @@ class TournamentsController < ApplicationController return render_timer_validation_error('Invalid timer_mode') end - timer_reason_error = validate_timer_reason_params(timer_reason, timer_reason_text) + timer_reason_error = validate_timer_reason_params( + timer_reason, + timer_reason_text, + timer_reason_param_present, + timer_reason_text_param_present + ) return render_timer_validation_error(timer_reason_error) if timer_reason_error # throw error if both timestamp and timestamp_seconds are present @@ -428,6 +437,8 @@ class TournamentsController < ApplicationController params[:timestamp] = Time.zone.now elsif timer_mode.present? return render_timer_validation_error('timer_mode requires timestamp, timestamp_seconds, or clear_timer') + elsif timer_reason_update_requested + return render_timer_validation_error('timer_reason requires active timer') unless timer_active? else return render_timer_validation_error('timestamp, timestamp_seconds, or clear_timer is required') end @@ -456,8 +467,17 @@ class TournamentsController < ApplicationController params[:timer_reason_text] = nil end - def validate_timer_reason_params(timer_reason, timer_reason_text) + def validate_timer_reason_params( + timer_reason, + timer_reason_text, + timer_reason_param_present, + timer_reason_text_param_present + ) + if timer_reason_text_param_present && !timer_reason_param_present + return 'timer_reason is required when timer_reason_text is provided' + end return if timer_reason.blank? && timer_reason_text.blank? + return 'timer_reason_text is only allowed when timer_reason is custom' if timer_reason.blank? return 'Invalid timer_reason' unless Tournament::TIMER_REASONS.include?(timer_reason) @@ -470,6 +490,10 @@ class TournamentsController < ApplicationController 'timer_reason_text is only allowed when timer_reason is custom' end + def timer_active? + @tournament.timestamp.present? && @tournament.timer_mode.present? + end + def render_timer_validation_error(message) render json: { error: message }, status: :unprocessable_content end diff --git a/doc/timer_api.md b/doc/timer_api.md index f1d0622..b60447f 100644 --- a/doc/timer_api.md +++ b/doc/timer_api.md @@ -185,6 +185,39 @@ Useful for: - rehydrating synced follower state - advanced admin tooling +### Change timer reason without restarting + +After a timer is active, owner may change only the reason fields. +Backend keeps current `timestamp` and `timer_mode` unchanged. + +Set a predefined reason: + +```json +{ + "timer_reason": "playoff_start" +} +``` + +Set a custom reason: + +```json +{ + "timer_reason": "custom", + "timer_reason_text": "Opening ceremony ran long" +} +``` + +Remove reason: + +```json +{ + "timer_reason": null +} +``` + +Response shape is the normal timer state with the same active `timestamp` and +`timer_mode`, plus updated reason fields. + ### Clear timer Request: @@ -215,6 +248,8 @@ Backend rejects: - `timer_mode` by itself unless it is `countup` - `clear_timer` mixed with timer values - invalid `timer_reason` +- reason-only updates when no timer is active +- `timer_reason_text` without `timer_reason` - `timer_reason = "custom"` without `timer_reason_text` - `timer_reason_text` with any non-custom `timer_reason` @@ -338,6 +373,7 @@ Implemented coverage: - API surface timer lifecycle: - countdown - countup + - reason-only set / change / remove - clear - validation - [spec/e2e/http/api_surface_spec.rb](../spec/e2e/http/api_surface_spec.rb) diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4f396b3..9f76c7e 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -307,6 +307,53 @@ RSpec.describe TournamentsController, type: :controller do expect(deserialize_response(response)[:timer_reason_text]).to be_nil end + it 'updates timer reason without changing active timer clock' do + original_timestamp = Time.utc(2030, 4, 13, 18, 0, 0) + @tournament.update!( + timestamp: original_timestamp, + timer_mode: 'countdown', + timer_reason: 'tournament_start' + ) + + patch :set_timer, params: { + id: @tournament.to_param, + timer_reason: 'custom', + timer_reason_text: 'Schedule pause' + } + + expect(response).to be_successful + expect(@tournament.reload.timestamp).to eq(original_timestamp) + expect(@tournament.timer_mode).to eq('countdown') + expect(@tournament.timer_reason).to eq('custom') + expect(@tournament.timer_reason_text).to eq('Schedule pause') + expect(deserialize_response(response)[:timestamp]).to eq(original_timestamp.iso8601) + expect(deserialize_response(response)[:timer_mode]).to eq('countdown') + expect(deserialize_response(response)[:timer_reason]).to eq('custom') + expect(deserialize_response(response)[:timer_reason_text]).to eq('Schedule pause') + + patch :set_timer, params: { + id: @tournament.to_param, + timer_reason: '' + } + + expect(response).to be_successful + expect(@tournament.reload.timestamp).to eq(original_timestamp) + expect(@tournament.timer_mode).to eq('countdown') + expect(@tournament.timer_reason).to be_nil + expect(@tournament.timer_reason_text).to be_nil + expect(deserialize_response(response)[:timestamp]).to eq(original_timestamp.iso8601) + expect(deserialize_response(response)[:timer_mode]).to eq('countdown') + expect(deserialize_response(response)[:timer_reason]).to be_nil + expect(deserialize_response(response)[:timer_reason_text]).to be_nil + end + + it 'rejects timer reason updates when no timer is active' do + patch :set_timer, params: { id: @tournament.to_param, timer_reason: 'match_end' } + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq('timer_reason requires active timer') + end + it 'rejects timestamp_seconds for countup mode' do patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup', timestamp_seconds: 120 } diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index f8600d7..45a1531 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -139,6 +139,13 @@ RSpec.describe 'Backend API surface HTTP E2E' do timer_reason: 'tournament_start' }) timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer") + timer_reason_custom = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timer_reason: 'custom', + timer_reason_text: 'Pause ran long' + }) + timer_reason_remove = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timer_reason: nil + }) timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup', timer_reason: 'custom', @@ -194,6 +201,16 @@ RSpec.describe 'Backend API surface HTTP E2E' do 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_show.dig(:json, :timer_reason)).to eq('tournament_start') + expect(timer_reason_custom[:status]).to eq(200) + expect(timer_reason_custom.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp)) + expect(timer_reason_custom.dig(:json, :timer_mode)).to eq('countdown') + expect(timer_reason_custom.dig(:json, :timer_reason)).to eq('custom') + expect(timer_reason_custom.dig(:json, :timer_reason_text)).to eq('Pause ran long') + expect(timer_reason_remove[:status]).to eq(200) + expect(timer_reason_remove.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp)) + expect(timer_reason_remove.dig(:json, :timer_mode)).to eq('countdown') + expect(timer_reason_remove.dig(:json, :timer_reason)).to be_nil + expect(timer_reason_remove.dig(:json, :timer_reason_text)).to be_nil expect(timer_countup[:status]).to eq(200) expect(timer_countup.dig(:json, :timestamp)).to be_a(String) expect(timer_countup.dig(:json, :timer_mode)).to eq('countup') 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 4112807..5b101d6 100644 --- a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb @@ -55,6 +55,35 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do expect(countdown_payload.fetch('timer_reason')).to eq('match_end') expect(countdown_payload.fetch('timer_reason_text')).to be_nil + reason_update = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { + timer_reason: 'custom', + timer_reason_text: 'Follower reason after start' + }) + expect(reason_update[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) + + reason_payload = wait_for_timer_payload_matching!(cable_client) do |payload| + payload.fetch('timestamp') == countdown.dig(:json, :timestamp) && + payload.fetch('timer_mode') == 'countdown' && + payload.fetch('timer_reason') == 'custom' && + payload.fetch('timer_reason_text') == 'Follower reason after start' + end + expect(reason_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) + + reason_clear = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { + timer_reason: nil + }) + expect(reason_clear[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) + + reason_cleared_payload = wait_for_timer_payload_matching!(cable_client) do |payload| + payload.fetch('timestamp') == countdown.dig(:json, :timestamp) && + payload.fetch('timer_mode') == 'countdown' && + payload.fetch('timer_reason').nil? && + payload.fetch('timer_reason_text').nil? + end + expect(reason_cleared_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) + countup = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { timer_mode: 'countup', timer_reason: 'custom', @@ -121,10 +150,16 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do end def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20) + wait_for_timer_payload_matching!(cable_client, timeout: timeout) do |payload| + payload.fetch('timer_mode') == timer_mode + end + end + + def wait_for_timer_payload_matching!(cable_client, 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 + return payload if yield(payload) raise "follower timer websocket payload not received within #{timeout}s" if Time.now >= deadline end diff --git a/spec/e2e/http/tournament_timer_websocket_spec.rb b/spec/e2e/http/tournament_timer_websocket_spec.rb index 73913bb..69fead4 100644 --- a/spec/e2e/http/tournament_timer_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_websocket_spec.rb @@ -45,6 +45,33 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do expect(countdown_payload.fetch('timer_reason')).to eq('playoff_start') expect(countdown_payload.fetch('timer_reason_text')).to be_nil + reason_update = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timer_reason: 'custom', + timer_reason_text: 'Reason changed after start' + }) + expect(reason_update[:status]).to eq(200) + + reason_payload = wait_for_timer_payload_matching!(cable_client) do |payload| + payload.fetch('timestamp') == countdown.dig(:json, :timestamp) && + payload.fetch('timer_mode') == 'countdown' && + payload.fetch('timer_reason') == 'custom' && + payload.fetch('timer_reason_text') == 'Reason changed after start' + end + expect(reason_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) + + reason_clear = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timer_reason: nil + }) + expect(reason_clear[:status]).to eq(200) + + reason_cleared_payload = wait_for_timer_payload_matching!(cable_client) do |payload| + payload.fetch('timestamp') == countdown.dig(:json, :timestamp) && + payload.fetch('timer_mode') == 'countdown' && + payload.fetch('timer_reason').nil? && + payload.fetch('timer_reason_text').nil? + end + expect(reason_cleared_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) + countup = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup', timer_reason: 'custom', @@ -90,10 +117,16 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do end def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20) + wait_for_timer_payload_matching!(cable_client, timeout: timeout) do |payload| + payload.fetch('timer_mode') == timer_mode + end + end + + def wait_for_timer_payload_matching!(cable_client, 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 + return payload if yield(payload) raise "timer websocket payload not received within #{timeout}s" if Time.now >= deadline end diff --git a/tasks.py b/tasks.py index 2e5efe6..4ce0978 100644 --- a/tasks.py +++ b/tasks.py @@ -633,10 +633,17 @@ def _start_server(command, env, log_path="/tmp/turniere-e2e-server.log"): return process, log_handle +def _server_env(env=None): + server_env = _env(GIT_COMMIT_SHA=_git_commit_sha()) + if _rails_env(env) == "test": + server_env["WEB_CONCURRENCY"] = os.environ.get("WEB_CONCURRENCY", "1") + return server_env + + def _start_test_server(host=SERVER_HOST, port=SERVER_PORT): return _start_server( f"{_bundle_exec('rails', 's')} -e test -b {host} -p {port}", - _env(GIT_COMMIT_SHA=_git_commit_sha()), + _server_env("test"), ) @@ -704,7 +711,7 @@ def server(c, host=SERVER_HOST, port=SERVER_PORT, env=None): """Start the backend locally in development.""" c.run( f"RAILS_ENV={_rails_env(env)} {_bundle_exec('rails', 's')} -b {host} -p {port}", - env=_env(GIT_COMMIT_SHA=_git_commit_sha()), + env=_server_env(env), pty=True, )