diff --git a/app/controllers/bets_controller.rb b/app/controllers/bets_controller.rb new file mode 100644 index 0000000..cc6e250 --- /dev/null +++ b/app/controllers/bets_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class BetsController < ApplicationController + before_action :set_match, only: %i[index create] + before_action :authenticate_user!, only: %i[create] + rescue_from UserServiceError, with: :handle_user_service_error + + def index + render json: @match.bets.group_by(&:team).map { |team, bets| + { + team: ActiveModelSerializers::SerializableResource.new(team).as_json, + bets: bets.size + } + } + end + + def create + render json: user_service.bet!(@match, Team.find_by(id: params[:team])) + end + + private + + def user_service + @user_service ||= UserService.new current_user + end + + def set_match + @match = Match.find params[:match_id] + end + + def handle_user_service_error(exception) + render json: { error: exception.message }, status: :unprocessable_entity + end +end diff --git a/app/errors/user_service_error.rb b/app/errors/user_service_error.rb new file mode 100644 index 0000000..9ef94df --- /dev/null +++ b/app/errors/user_service_error.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class UserServiceError < StandardError +end diff --git a/app/models/bet.rb b/app/models/bet.rb new file mode 100644 index 0000000..d9a736d --- /dev/null +++ b/app/models/bet.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Bet < ApplicationRecord + belongs_to :user + belongs_to :match + belongs_to :team, optional: true +end diff --git a/app/models/match.rb b/app/models/match.rb index bc80977..7891276 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -5,7 +5,9 @@ class Match < ApplicationRecord belongs_to :stage, optional: true belongs_to :group, optional: true + has_many :match_scores, dependent: :destroy + has_many :bets, dependent: :destroy validates :match_scores, length: { maximum: 2 } diff --git a/app/models/team.rb b/app/models/team.rb index f266f9c..d81563e 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -4,6 +4,7 @@ class Team < ApplicationRecord belongs_to :tournament, optional: true has_many :group_scores, dependent: :destroy has_many :match_scores, dependent: :destroy + has_many :bets, dependent: :destroy validates :name, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index a45995e..c21ab22 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,4 +11,5 @@ class User < ApplicationRecord validates :username, presence: true, uniqueness: { case_sensitive: false } has_many :tournaments, dependent: :destroy + has_many :bets, dependent: :destroy end diff --git a/app/serializers/bet_serializer.rb b/app/serializers/bet_serializer.rb new file mode 100644 index 0000000..9dc798e --- /dev/null +++ b/app/serializers/bet_serializer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class BetSerializer < ApplicationSerializer + belongs_to :match + belongs_to :team +end diff --git a/app/services/user_service.rb b/app/services/user_service.rb new file mode 100644 index 0000000..f950f0e --- /dev/null +++ b/app/services/user_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class UserService + def initialize(user) + @user = user + end + + def bet!(match, team) + validate_bet! match, team + @user.bets.create! match: match, team: team + end + + private + + def validate_bet!(match, team) + if team.nil? + raise UserServiceError, 'Betting on no team in a playoff match is not supported' unless match.group_match? + else + raise UserServiceError, 'The given team is not involved in the given match' unless match.teams.include? team + end + raise UserServiceError, 'This user already created a bet on this match' if match.bets.map(&:user).include? @user + raise UserServiceError, "Betting is not allowed while match is #{match.state}" \ + unless %w[not_ready not_started].include? match.state + end +end diff --git a/config/routes.rb b/config/routes.rb index 63c742d..d8f761a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,7 +6,9 @@ Rails.application.routes.draw do sessions: 'overrides/sessions' } - resources :matches, only: %i[show update] + resources :matches, only: %i[show update] do + resources :bets, only: %i[index create] + end resources :teams, only: %i[show update] resources :tournaments do resources :statistics, only: %i[index] diff --git a/db/migrate/0001_create_bets.rb b/db/migrate/0001_create_bets.rb new file mode 100644 index 0000000..08b83ea --- /dev/null +++ b/db/migrate/0001_create_bets.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateBets < ActiveRecord::Migration[5.2] + def change + create_table :bets do |t| + t.references :user, index: true, null: false, foreign_key: { on_delete: :cascade } + t.references :match, index: true, null: false, foreign_key: { on_delete: :cascade } + t.references :team, index: true, null: true, foreign_key: { on_delete: :cascade } + + t.timestamps + end + end +end diff --git a/spec/controllers/bets_controller_spec.rb b/spec/controllers/bets_controller_spec.rb new file mode 100644 index 0000000..8587bb2 --- /dev/null +++ b/spec/controllers/bets_controller_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BetsController, type: :controller do + let(:team) do + create(:team) + end + + let(:match) do + match = create(:playoff_match) + match.bets << create(:bet, team: team) + match + end + + let(:params) do + { + match_id: match.to_param + } + end + + describe 'GET #index' do + it 'returns a list of bet counts' do + get :index, params: params + body = deserialize_response response + expect(body.size).to eq(1) + expect(body.first[:team][:id]).to eq(team.id) + expect(body.first[:bets]).to eq(1) + end + end + + describe 'POST #create' do + let(:create_params) do + params.merge(team: team.to_param) + end + + let(:user_service) do + instance_double('UserService') + end + + before do + allow(controller).to receive(:user_service).and_return(user_service) + end + + context 'without authentication headers' do + it 'renders an unauthorized error response' do + post :create, params: params + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with authentication headers' do + before(:each) do + apply_authentication_headers_for create(:user) + end + + it 'returns the created bet' do + bet = create(:bet) + expect(user_service).to receive(:bet!).and_return(bet) + post :create, params: create_params + expect(response).to be_successful + body = deserialize_response(response) + expect(body[:id]).to eq(bet.id) + end + + context 'given a team' do + it 'calls the service' do + expect(user_service).to receive(:bet!).with(match, team) + post :create, params: create_params + end + end + + context 'given no team' do + it 'calls the service' do + expect(user_service).to receive(:bet!).with(match, nil) + post :create, params: params.merge(team: nil) + end + end + + context 'on service exception' do + it 'returns an error response' do + msg = 'an error' + expect(user_service).to receive(:bet!).and_raise(UserServiceError, msg) + post :create, params: create_params + expect(response).to have_http_status(:unprocessable_entity) + expect(deserialize_response(response)[:error]).to eq(msg) + end + end + end + end +end diff --git a/spec/factories/bets.rb b/spec/factories/bets.rb new file mode 100644 index 0000000..6cc28df --- /dev/null +++ b/spec/factories/bets.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :bet do + user + team + match + end +end diff --git a/spec/models/bet_spec.rb b/spec/models/bet_spec.rb new file mode 100644 index 0000000..06e7a78 --- /dev/null +++ b/spec/models/bet_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Bet, type: :model do + describe 'association' do + it { should belong_to :user } + it { should belong_to :match } + it { should belong_to(:team).optional } + end +end diff --git a/spec/models/match_spec.rb b/spec/models/match_spec.rb index 1491bfa..6610b96 100644 --- a/spec/models/match_spec.rb +++ b/spec/models/match_spec.rb @@ -5,6 +5,7 @@ require 'rails_helper' RSpec.describe Match, type: :model do context 'association' do it { should have_many :match_scores } + it { should have_many :bets } it { should belong_to(:stage).optional } it { should belong_to(:group).optional } end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index c2f25e3..d6d097e 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -11,5 +11,6 @@ RSpec.describe Team, type: :model do it { should belong_to(:tournament).optional } it { should have_many :group_scores } it { should have_many :match_scores } + it { should have_many :bets } end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 7216b64..83ab130 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -5,6 +5,7 @@ require 'rails_helper' RSpec.describe User, type: :model do describe 'association' do it { should have_many :tournaments } + it { should have_many :bets } end describe 'validation' do diff --git a/spec/services/user_service_spec.rb b/spec/services/user_service_spec.rb new file mode 100644 index 0000000..b0e8c22 --- /dev/null +++ b/spec/services/user_service_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +RSpec.describe UserService do + let(:user) do + create(:user) + end + + let(:user_service) do + UserService.new(user) + end + + let(:team) do + create(:team) + end + + def build_match(involved_team = team, factory = :playoff_match) + create(factory, state: :not_started, match_scores: [create(:match_score, team: involved_team)]) + end + + describe '#bet!' do + context 'with an unrelated team' do + it 'throws an exception' do + expect do + user_service.bet! build_match(create(:team)), team + end.to raise_error(UserServiceError, 'The given team is not involved in the given match') + end + end + + context 'on a running match' do + it 'throws an exception' do + match = build_match + match.state = :in_progress + expect do + user_service.bet! match, team + end.to raise_error(UserServiceError, 'Betting is not allowed while match is in_progress') + end + end + + context 'with an existing team' do + let(:match) do + build_match + end + + let!(:bet) do + user_service.bet! match, team + end + + it 'associates the bet with the given team' do + expect(team.bets.reload).to include(bet) + end + + it 'associates the bet with the given match' do + expect(match.bets.reload).to include(bet) + end + + it 'associates the bet with the creating user' do + expect(user.bets.reload).to include(bet) + end + + context 'with an already existing bet' do + it 'throws an exception' do + match = build_match + user_service.bet! match, team + user.reload + match.reload + expect do + user_service.bet! match, team + end.to raise_error(UserServiceError, 'This user already created a bet on this match') + end + end + end + + context 'without a team' do + context 'on a playoff stage' do + it 'throws an exception' do + expect do + user_service.bet! build_match, nil + end.to raise_error(UserServiceError, 'Betting on no team in a playoff match is not supported') + end + end + + context 'on a group stage' do + it 'succeeds' do + user_service.bet! build_match(team, :group_match), nil + end + end + end + end +end