Merge remote-tracking branch 'origin/master' into codex/tur-225-user-organizer-accounts
This commit is contained in:
commit
aba4c1c23d
|
|
@ -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)
|
||||
|
|
@ -378,6 +379,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]
|
||||
|
|
@ -391,7 +395,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
|
||||
|
|
@ -435,6 +444,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
|
||||
|
|
@ -463,8 +474,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)
|
||||
|
||||
|
|
@ -477,6 +497,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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -203,6 +203,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',
|
||||
|
|
@ -258,6 +265,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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
tasks.py
11
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,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue