From 19f744daebea3276ebc382e36f94c4bbf48cbbf2 Mon Sep 17 00:00:00 2001 From: Tobias Huber Date: Mon, 13 Apr 2026 20:01:48 +0200 Subject: [PATCH 1/2] Add Beamer entity with CRUD API and name-based lookup New Beamer model scoped to tournaments for multi-projector display control. Includes migration, validations, serializer, controller with by_name fallback endpoint, routing, and full RSpec test coverage. Co-Authored-By: Claude Opus 4.6 --- app/controllers/beamers_controller.rb | 69 +++++++ app/models/beamer.rb | 17 ++ app/models/tournament.rb | 1 + app/serializers/beamer_serializer.rb | 5 + config/routes.rb | 5 + db/migrate/20260412230000_create_beamers.rb | 16 ++ spec/controllers/beamers_controller_spec.rb | 194 ++++++++++++++++++++ spec/factories/beamers.rb | 15 ++ spec/models/beamer_spec.rb | 58 ++++++ spec/models/tournament_spec.rb | 1 + spec/routing/beamers_routing_spec.rb | 43 +++++ 11 files changed, 424 insertions(+) create mode 100644 app/controllers/beamers_controller.rb create mode 100644 app/models/beamer.rb create mode 100644 app/serializers/beamer_serializer.rb create mode 100644 db/migrate/20260412230000_create_beamers.rb create mode 100644 spec/controllers/beamers_controller_spec.rb create mode 100644 spec/factories/beamers.rb create mode 100644 spec/models/beamer_spec.rb create mode 100644 spec/routing/beamers_routing_spec.rb diff --git a/app/controllers/beamers_controller.rb b/app/controllers/beamers_controller.rb new file mode 100644 index 0000000..9e36187 --- /dev/null +++ b/app/controllers/beamers_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class BeamersController < ApplicationController + before_action :set_tournament + before_action :set_beamer, only: %i[show update destroy] + before_action :authenticate_user!, only: %i[create update destroy] + before_action -> { require_owner! @tournament.owner }, only: %i[create update destroy] + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error + + # GET /tournaments/:tournament_id/beamers + def index + render json: @tournament.beamers, each_serializer: BeamerSerializer + end + + # GET /tournaments/:tournament_id/beamers/:id + def show + render json: @beamer + end + + # GET /tournaments/:tournament_id/beamers/by_name?name=beamername + def by_name + beamer = @tournament.beamers.find_by(name: params[:name]) + beamer ||= @tournament.beamers.default_beamer.first + if beamer + render json: beamer + else + render json: { error: 'No beamer found' }, status: :not_found + end + end + + # POST /tournaments/:tournament_id/beamers + def create + beamer = @tournament.beamers.new(beamer_params) + if beamer.save + render json: beamer, status: :created + else + render json: beamer.errors, status: :unprocessable_entity + end + end + + # PATCH /tournaments/:tournament_id/beamers/:id + def update + if @beamer.update(beamer_params) + render json: @beamer + else + render json: @beamer.errors, status: :unprocessable_entity + end + end + + # DELETE /tournaments/:tournament_id/beamers/:id + def destroy + @beamer.destroy + head :no_content + end + + private + + def set_tournament + @tournament = Tournament.find(params[:tournament_id]) + end + + def set_beamer + @beamer = @tournament.beamers.find(params[:id]) + end + + def beamer_params + params.permit(:name, :display_state, :is_default, :qr_code_url, :logo_url) + end +end diff --git a/app/models/beamer.rb b/app/models/beamer.rb new file mode 100644 index 0000000..e168f1c --- /dev/null +++ b/app/models/beamer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Beamer < ApplicationRecord + DISPLAY_STATES = %w[ + matches_all matches_in_progress matches_upcoming matches_not_started + matches_finished groups between_games during_game + ].freeze + + belongs_to :tournament + + validates :name, presence: true, uniqueness: { scope: :tournament_id } + validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES } + + scope :default_beamer, -> { where(is_default: true) } + + delegate :owner, to: :tournament +end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 1f02015..0fbe8a4 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -6,6 +6,7 @@ class Tournament < ApplicationRecord belongs_to :user has_many :teams, dependent: :destroy has_many :stages, dependent: :destroy + has_many :beamers, dependent: :destroy validates :name, presence: true validates :code, presence: true, uniqueness: true diff --git a/app/serializers/beamer_serializer.rb b/app/serializers/beamer_serializer.rb new file mode 100644 index 0000000..62d9fc6 --- /dev/null +++ b/app/serializers/beamer_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class BeamerSerializer < ApplicationSerializer + attributes :name, :display_state, :is_default, :qr_code_url, :logo_url +end diff --git a/config/routes.rb b/config/routes.rb index 0351e81..18aa961 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,11 @@ Rails.application.routes.draw do resources :tournaments do resources :statistics, only: %i[index] resources :matches, only: %i[index] + resources :beamers, only: %i[index show create update destroy] do + collection do + get :by_name + end + end member do get :timer_end patch :set_timer_end diff --git a/db/migrate/20260412230000_create_beamers.rb b/db/migrate/20260412230000_create_beamers.rb new file mode 100644 index 0000000..f32c2cb --- /dev/null +++ b/db/migrate/20260412230000_create_beamers.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateBeamers < ActiveRecord::Migration[7.0] + def change + create_table :beamers do |t| + t.string :name, null: false + t.string :display_state, null: false, default: 'matches_all' + t.boolean :is_default, default: false + t.string :qr_code_url + t.string :logo_url + t.belongs_to :tournament, index: true, foreign_key: { on_delete: :cascade }, null: false + t.timestamps + end + add_index :beamers, %i[tournament_id name], unique: true + end +end diff --git a/spec/controllers/beamers_controller_spec.rb b/spec/controllers/beamers_controller_spec.rb new file mode 100644 index 0000000..470f59c --- /dev/null +++ b/spec/controllers/beamers_controller_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BeamersController, type: :controller do + before do + @tournament = create(:tournament) + @owner = @tournament.owner + @beamer = create(:beamer, tournament: @tournament) + @default_beamer = create(:default_beamer, tournament: @tournament) + end + + describe 'GET #index' do + it 'returns all beamers for the tournament' do + get :index, params: { tournament_id: @tournament.to_param } + expect(response).to be_successful + body = deserialize_response response + expect(body.length).to eq(2) + end + end + + describe 'GET #show' do + it 'returns the beamer' do + get :show, params: { tournament_id: @tournament.to_param, id: @beamer.to_param } + expect(response).to be_successful + body = deserialize_response response + expect(body[:name]).to eq(@beamer.name) + expect(body[:display_state]).to eq(@beamer.display_state) + end + + it 'returns 404 for nonexistent beamer' do + get :show, params: { tournament_id: @tournament.to_param, id: 999_999 } + expect(response).to have_http_status(:not_found) + end + end + + describe 'GET #by_name' do + it 'returns beamer by name' do + get :by_name, params: { tournament_id: @tournament.to_param, name: @beamer.name } + expect(response).to be_successful + body = deserialize_response response + expect(body[:name]).to eq(@beamer.name) + end + + it 'falls back to default beamer when name not found' do + get :by_name, params: { tournament_id: @tournament.to_param, name: 'nonexistent' } + expect(response).to be_successful + body = deserialize_response response + expect(body[:name]).to eq(@default_beamer.name) + expect(body[:is_default]).to be true + end + + it 'falls back to default beamer when name param is missing' do + get :by_name, params: { tournament_id: @tournament.to_param } + expect(response).to be_successful + body = deserialize_response response + expect(body[:is_default]).to be true + end + + it 'returns 404 when no beamers exist at all' do + empty_tournament = create(:tournament) + get :by_name, params: { tournament_id: empty_tournament.to_param, name: 'anything' } + expect(response).to have_http_status(:not_found) + end + end + + describe 'POST #create' do + let(:valid_params) do + { name: 'new-beamer', display_state: 'groups', is_default: false } + end + + context 'as owner' do + before(:each) { apply_authentication_headers_for @owner } + + it 'creates a new beamer' do + expect { + post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params) + }.to change(@tournament.beamers, :count).by(1) + expect(response).to have_http_status(:created) + end + + it 'returns the created beamer' do + post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params) + body = deserialize_response response + expect(body[:name]).to eq('new-beamer') + expect(body[:display_state]).to eq('groups') + end + + it 'rejects invalid display_state' do + post :create, params: { + tournament_id: @tournament.to_param, name: 'bad', display_state: 'invalid' + } + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'rejects duplicate name in same tournament' do + post :create, params: { + tournament_id: @tournament.to_param, name: @beamer.name, display_state: 'matches_all' + } + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as another user' do + before(:each) { apply_authentication_headers_for create(:user) } + + it 'returns forbidden' do + post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params) + expect(response).to have_http_status(:forbidden) + end + end + + context 'unauthenticated' do + it 'returns unauthorized' do + post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params) + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PATCH #update' do + context 'as owner' do + before(:each) { apply_authentication_headers_for @owner } + + it 'updates the display_state' do + patch :update, params: { + tournament_id: @tournament.to_param, id: @beamer.to_param, + display_state: 'during_game' + } + expect(response).to be_successful + body = deserialize_response response + expect(body[:display_state]).to eq('during_game') + expect(@beamer.reload.display_state).to eq('during_game') + end + + it 'updates optional fields' do + patch :update, params: { + tournament_id: @tournament.to_param, id: @beamer.to_param, + qr_code_url: 'https://example.com/qr', logo_url: '/static/logo.png' + } + expect(response).to be_successful + @beamer.reload + expect(@beamer.qr_code_url).to eq('https://example.com/qr') + expect(@beamer.logo_url).to eq('/static/logo.png') + end + + it 'rejects invalid display_state' do + patch :update, params: { + tournament_id: @tournament.to_param, id: @beamer.to_param, + display_state: 'bogus' + } + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as another user' do + before(:each) { apply_authentication_headers_for create(:user) } + + it 'returns forbidden' do + patch :update, params: { + tournament_id: @tournament.to_param, id: @beamer.to_param, + display_state: 'groups' + } + expect(response).to have_http_status(:forbidden) + end + end + end + + describe 'DELETE #destroy' do + context 'as owner' do + before(:each) { apply_authentication_headers_for @owner } + + it 'deletes the beamer' do + expect { + delete :destroy, params: { + tournament_id: @tournament.to_param, id: @beamer.to_param + } + }.to change(@tournament.beamers, :count).by(-1) + expect(response).to have_http_status(:no_content) + end + end + + context 'as another user' do + before(:each) { apply_authentication_headers_for create(:user) } + + it 'returns forbidden' do + delete :destroy, params: { + tournament_id: @tournament.to_param, id: @beamer.to_param + } + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/factories/beamers.rb b/spec/factories/beamers.rb new file mode 100644 index 0000000..e4dfa4e --- /dev/null +++ b/spec/factories/beamers.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :beamer do + sequence(:name) { |n| "beamer-#{n}" } + display_state { 'matches_all' } + is_default { false } + tournament + + factory :default_beamer do + name { 'default' } + is_default { true } + end + end +end diff --git a/spec/models/beamer_spec.rb b/spec/models/beamer_spec.rb new file mode 100644 index 0000000..a842190 --- /dev/null +++ b/spec/models/beamer_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Beamer, type: :model do + describe 'validation' do + it { should validate_presence_of :name } + it { should validate_presence_of :display_state } + + it 'validates uniqueness of name scoped to tournament' do + beamer = create(:beamer) + duplicate = build(:beamer, name: beamer.name, tournament: beamer.tournament) + expect(duplicate).not_to be_valid + end + + it 'allows same name in different tournaments' do + beamer = create(:beamer, name: 'projector') + other = build(:beamer, name: 'projector', tournament: create(:tournament)) + expect(other).to be_valid + end + + it 'validates display_state inclusion' do + beamer = build(:beamer, display_state: 'invalid_state') + expect(beamer).not_to be_valid + end + + Beamer::DISPLAY_STATES.each do |state| + it "accepts display_state '#{state}'" do + beamer = build(:beamer, display_state: state) + expect(beamer).to be_valid + end + end + end + + describe 'association' do + it { should belong_to :tournament } + end + + describe 'scopes' do + describe '.default_beamer' do + it 'returns only default beamers' do + tournament = create(:tournament) + default = create(:default_beamer, tournament: tournament) + create(:beamer, tournament: tournament) + + result = tournament.beamers.default_beamer + expect(result).to contain_exactly(default) + end + end + end + + describe '#owner' do + it 'delegates to tournament' do + beamer = create(:beamer) + expect(beamer.owner).to eq(beamer.tournament.owner) + end + end +end diff --git a/spec/models/tournament_spec.rb b/spec/models/tournament_spec.rb index a555abe..fde092b 100644 --- a/spec/models/tournament_spec.rb +++ b/spec/models/tournament_spec.rb @@ -29,6 +29,7 @@ RSpec.describe Tournament, type: :model do it { should belong_to :user } it { should have_many :teams } it { should have_many :stages } + it { should have_many :beamers } end describe '#matches' do diff --git a/spec/routing/beamers_routing_spec.rb b/spec/routing/beamers_routing_spec.rb new file mode 100644 index 0000000..b6dcce6 --- /dev/null +++ b/spec/routing/beamers_routing_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BeamersController, type: :routing do + describe 'routing' do + it 'routes to #index' do + expect(get: '/tournaments/1/beamers').to route_to( + 'beamers#index', tournament_id: '1' + ) + end + + it 'routes to #show' do + expect(get: '/tournaments/1/beamers/2').to route_to( + 'beamers#show', tournament_id: '1', id: '2' + ) + end + + it 'routes to #by_name' do + expect(get: '/tournaments/1/beamers/by_name').to route_to( + 'beamers#by_name', tournament_id: '1' + ) + end + + it 'routes to #create' do + expect(post: '/tournaments/1/beamers').to route_to( + 'beamers#create', tournament_id: '1' + ) + end + + it 'routes to #update via PATCH' do + expect(patch: '/tournaments/1/beamers/2').to route_to( + 'beamers#update', tournament_id: '1', id: '2' + ) + end + + it 'routes to #destroy' do + expect(delete: '/tournaments/1/beamers/2').to route_to( + 'beamers#destroy', tournament_id: '1', id: '2' + ) + end + end +end From 58267b6b3affe7d214e9405384ef0ca6e5ca87a8 Mon Sep 17 00:00:00 2001 From: Tobias Huber Date: Mon, 13 Apr 2026 23:30:29 +0200 Subject: [PATCH 2/2] Fix timer timezone handling --- app/controllers/tournaments_controller.rb | 66 ++++++++++--------- app/serializers/tournament_serializer.rb | 6 +- .../tournaments_controller_spec.rb | 28 ++++++++ 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 85d1eb3..62ad410 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -96,7 +96,7 @@ class TournamentsController < ApplicationController # GET /tournaments/:id/timer_end def timer_end - render json: { timer_end: @tournament.timer_end } + render json: { timer_end: serialize_timer_end(@tournament.timer_end) } end # PATCH /tournaments/:id/set_timer_end @@ -115,6 +115,10 @@ class TournamentsController < ApplicationController { timer_end: params[:timer_end] } end + def serialize_timer_end(timer_end) + timer_end&.iso8601 + end + def organize_teams_in_groups(teams) # each team gets put into an array of teams depending on the group specified in team[:group] teams.group_by { |team| team['group'] }.values.map do |group| @@ -177,40 +181,42 @@ class TournamentsController < ApplicationController error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match' }, status: :unprocessable_entity end -end -def validate_set_timer_end_params - timer_end = params[:timer_end] - timer_end_seconds = params[:timer_end_seconds] + def validate_set_timer_end_params + timer_end = params[:timer_end] + timer_end_seconds = params[:timer_end_seconds] - # 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_end_seconds.present? - begin - timer_end_seconds = Integer(timer_end_seconds) - rescue ArgumentError - return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity + # 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 - return render json: { error: 'Timer end must be in the future' }, status: :unprocessable_entity if timer_end_seconds <= 0 - - parsed_time = Time.zone.now + timer_end_seconds - params[:timer_end] = parsed_time - 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: 'Timer end must be in the future' }, status: :unprocessable_entity + if timer_end_seconds.present? + begin + timer_end_seconds = Integer(timer_end_seconds) + rescue ArgumentError + return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity end - rescue ArgumentError - return render json: { error: 'Invalid datetime format' }, status: :unprocessable_entity + + return render json: { error: 'Timer end must be in the future' }, status: :unprocessable_entity if timer_end_seconds <= 0 + + parsed_time = Time.zone.now + timer_end_seconds + params[:timer_end] = parsed_time + 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: 'Timer end must be in the future' }, status: :unprocessable_entity + end + + params[:timer_end] = parsed_time + rescue ArgumentError + return render json: { error: 'Invalid datetime format' }, status: :unprocessable_entity + end + else + return render json: { error: 'Timer end is required' }, status: :unprocessable_entity end - else - return render json: { error: 'Timer end is required' }, status: :unprocessable_entity end end diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index 751aa4f..854ed5c 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -2,9 +2,13 @@ class TournamentSerializer < SimpleTournamentSerializer attributes :description, :playoff_teams_amount, - :instant_finalists_amount, :intermediate_round_participants_amount, :timer_end + :instant_finalists_amount, :intermediate_round_participants_amount has_many :stages + attribute :timer_end do + object.timer_end&.iso8601 + end + attribute :owner_username do object.owner.username end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 92f3597..977b89a 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -142,6 +142,34 @@ RSpec.describe TournamentsController, type: :controller do end end + describe 'GET #timer_end' do + it 'returns timer_end in ISO 8601 format' do + timer_end = Time.utc(2026, 4, 13, 18, 0, 0) + @tournament.update!(timer_end: timer_end) + + get :timer_end, params: { id: @tournament.to_param } + + expect(response).to be_successful + expect(deserialize_response(response)[:timer_end]).to eq(timer_end.iso8601) + end + end + + describe 'PATCH #set_timer_end' do + before(:each) 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' + + patch :set_timer_end, params: { id: @tournament.to_param, timer_end: timer_end } + + 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) + end + end + describe 'POST #create' do let(:create_playoff_tournament_data) do {