add backend beamer

This commit is contained in:
Tobias Huber 2026-04-13 20:01:48 +02:00
parent 5d69f4fb07
commit 85fce7faac
11 changed files with 424 additions and 0 deletions

View File

@ -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

17
app/models/beamer.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class BeamerSerializer < ApplicationSerializer
attributes :name, :display_state, :is_default, :qr_code_url, :logo_url
end

View File

@ -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

View File

@ -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

View File

@ -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

15
spec/factories/beamers.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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