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