diff --git a/README.md b/README.md index 2fa0341..d8f90bc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# turniere-backend [![pipeline status](https://gitlab.com/turniere/turniere-backend/badges/master/pipeline.svg)](https://gitlab.com/turniere/turniere-backend/commits/master) [![Coverage Status](https://coveralls.io/repos/gitlab/turniere/turniere-backend/badge.svg?branch=ticket%2FTURNIERE-155)](https://coveralls.io/gitlab/turniere/turniere-backend?branch=ticket%2FTURNIERE-155) +# turniere-backend [![Build Status](https://travis-ci.org/turniere/turniere-backend.svg?branch=master)](https://travis-ci.org/turniere/turniere-backend) [![pipeline status](https://gitlab.com/turniere/turniere-backend/badges/master/pipeline.svg)](https://gitlab.com/turniere/turniere-backend/commits/master) [![Coverage Status](https://coveralls.io/repos/gitlab/turniere/turniere-backend/badge.svg?branch=master)](https://coveralls.io/gitlab/turniere/turniere-backend?branch=master) [![](https://img.shields.io/badge/Protected_by-Hound-a873d1.svg)](https://houndci.com) Ruby on Rails application serving as backend for turnie.re # Installation diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index e8ea5c0..6a4a3e5 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -31,25 +31,27 @@ class TournamentsController < ApplicationController def create params = tournament_params params.require(:teams) - # convert teams parameter into Team objects - teams = params.delete('teams').map do |team| - if team[:id] - Team.find team[:id] - elsif team[:name] - Team.create name: team[:name] - end - end + group_stage = params.delete(:group_stage) + teams = params.delete('teams') # create tournament tournament = current_user.tournaments.new params - # associate provided teams with tournament - tournament.teams = teams + if group_stage + groups = organize_teams_in_groups(teams) + # add groups to tournament + result = AddGroupStageToTournamentAndSaveTournamentToDatabase.call(tournament: tournament, groups: groups) + else + # convert teams parameter into Team objects + teams = teams.map(&method(:find_or_create_team)) + # associate provided teams with tournament + tournament.teams = teams + # add playoff stage to tournament + result = AddPlayoffsToTournamentAndSaveTournamentToDatabase.call(tournament: tournament) + end # validate tournament unless tournament.valid? render json: tournament.errors, status: :unprocessable_entity return end - # add playoff stage to tournament - result = AddPlayoffsToTournamentAndSaveTournamentToDatabase.call(tournament: tournament) # return appropriate result if result.success? render json: result.tournament, status: :created, location: result.tournament @@ -74,6 +76,24 @@ class TournamentsController < ApplicationController private + def organize_teams_in_groups(teams) + # each team gets put into a array of teams depending on the group specified in team[:group] + teams.group_by { |team| team['group'] }.values.map do |group| + group.map do |team| + find_or_create_team(team) + end + end + end + + def find_or_create_team(team) + # convert teams parameter into Team objects + if team[:id] + Team.find team[:id] + elsif team[:name] + Team.create name: team[:name] + end + end + def set_tournament @tournament = Tournament.find(params[:id]) end @@ -83,7 +103,7 @@ class TournamentsController < ApplicationController end def tournament_params - params.slice(:name, :description, :public, :teams).permit! + params.slice(:name, :description, :public, :teams, :group_stage).permit! end def validate_create_params diff --git a/app/interactors/add_group_stage_to_tournament.rb b/app/interactors/add_group_stage_to_tournament.rb new file mode 100644 index 0000000..14b798c --- /dev/null +++ b/app/interactors/add_group_stage_to_tournament.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddGroupStageToTournament + include Interactor + + def call + tournament = context.tournament + groups = context.groups + context.fail! unless tournament.stages.empty? + begin + group_stage = GroupStageService.generate_group_stage(groups) + tournament.stages = [group_stage] + context.tournament = tournament + rescue StandardError + context.fail! + end + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 39cba55..1b99378 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -4,4 +4,8 @@ class Group < ApplicationRecord belongs_to :stage has_many :matches, dependent: :destroy has_many :group_scores, dependent: :destroy + + def teams + matches.map(&:teams).flatten.uniq + end end diff --git a/app/models/match.rb b/app/models/match.rb index 0eb0c14..6c3eee8 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -11,6 +11,10 @@ class Match < ApplicationRecord validate :stage_xor_group + def teams + match_scores.map(&:team).flatten.uniq + end + private def stage_xor_group diff --git a/app/models/stage.rb b/app/models/stage.rb index b43d80c..48ad20d 100644 --- a/app/models/stage.rb +++ b/app/models/stage.rb @@ -4,4 +4,10 @@ class Stage < ApplicationRecord belongs_to :tournament has_many :matches, dependent: :destroy has_many :groups, dependent: :destroy + + def teams + return matches.map(&:teams).flatten.uniq unless matches.size.zero? + + groups.map(&:teams).flatten.uniq unless groups.size.zero? + end end diff --git a/app/organizers/add_group_stage_to_tournament_and_save_tournament_to_database.rb b/app/organizers/add_group_stage_to_tournament_and_save_tournament_to_database.rb new file mode 100644 index 0000000..83710b5 --- /dev/null +++ b/app/organizers/add_group_stage_to_tournament_and_save_tournament_to_database.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddGroupStageToTournamentAndSaveTournamentToDatabase + include Interactor::Organizer + + organize AddGroupStageToTournament, SaveTournamentToDatabase +end diff --git a/app/services/group_stage_service.rb b/app/services/group_stage_service.rb new file mode 100644 index 0000000..ea03168 --- /dev/null +++ b/app/services/group_stage_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class GroupStageService + def self.generate_group_stage(groups) + raise 'Cannot generate group stage without groups' if groups.length.zero? + + # raise an error if the average group size is not a whole number + raise 'Groups need to be equal size' unless (groups.flatten.length.to_f / groups.length.to_f % 1).zero? + + groups = groups.map(&method(:get_group_object_from)) + Stage.new level: -1, groups: groups + end + + def self.get_group_object_from(team_array) + Group.new matches: generate_all_matches_between(team_array) + end + + def self.generate_all_matches_between(teams) + matches = [] + teams.combination(2).to_a # = matchups + .each_with_index do |matchup, i| + match = Match.new state: :not_started, + position: i, + match_scores: [ + MatchScore.new(team: matchup.first), + MatchScore.new(team: matchup.second) + ] + matches << match + end + matches + end +end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 7dc9d2e..da27ca7 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -9,6 +9,8 @@ RSpec.describe TournamentsController, type: :controller do @another_user = create(:user) @private_tournament = create(:tournament, user: @another_user, public: false) @teams = create_list(:detached_team, 4) + @teams16 = create_list(:detached_team, 16) + @groups = create_list(:group, 4) end describe 'GET #index' do @@ -103,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do end describe 'POST #create' do - let(:create_data) do + let(:create_playoff_tournament_data) do { name: Faker::Creature::Dog.name, description: Faker::Lorem.sentence, @@ -112,9 +114,20 @@ RSpec.describe TournamentsController, type: :controller do } end + let(:create_group_tournament_data) do + teams_with_groups = @teams16.each_with_index.map { |team, i| { id: team.id, group: (i / 4).floor } } + { + name: Faker::TvShows::FamilyGuy.character, + description: Faker::Movies::HarryPotter.quote, + public: false, + group_stage: true, + teams: teams_with_groups + } + end + context 'without authentication headers' do it 'renders an unauthorized error response' do - put :create, params: create_data + put :create, params: create_playoff_tournament_data expect(response).to have_http_status(:unauthorized) end end @@ -127,40 +140,58 @@ RSpec.describe TournamentsController, type: :controller do context 'with existing teams' do it 'creates a new Tournament' do expect do - post :create, params: create_data + post :create, params: create_playoff_tournament_data end.to change(Tournament, :count).by(1) end it 'associates the new tournament with the authenticated user' do expect do - post :create, params: create_data + post :create, params: create_playoff_tournament_data end.to change(@user.tournaments, :count).by(1) end it 'associates the given teams with the created tournament' do - post :create, params: create_data + post :create, params: create_playoff_tournament_data body = deserialize_response response tournament = Tournament.find(body[:id]) expect(tournament.teams).to match_array(@teams) end it 'generates a playoff stage' do - post :create, params: create_data + post :create, params: create_playoff_tournament_data body = deserialize_response response tournament = Tournament.find(body[:id]) expect(tournament.stages.first).to be_a(Stage) end it 'generates a playoff stage with all given teams' do - post :create, params: create_data + post :create, params: create_playoff_tournament_data body = deserialize_response response tournament = Tournament.find(body[:id]) included_teams = tournament.stages.first.matches.map { |m| m.match_scores.map(&:team) }.flatten.uniq expect(included_teams).to match_array(@teams) end + context 'with parameter group_stage=true' do + before do + post :create, params: create_group_tournament_data + body = deserialize_response response + @group_stage_tournament = Tournament.find(body[:id]) + end + + it 'generates a group stage with all teams given in parameters' do + included_teams = @group_stage_tournament.stages.find_by(level: -1).teams + expect(included_teams).to match_array(@teams16) + end + + it 'generates a group stage' do + group_stage = @group_stage_tournament.stages.find_by(level: -1) + expect(group_stage).to be_a(Stage) + end + end + it 'renders a JSON response with the new tournament' do - post :create, params: create_data + post :create, params: create_playoff_tournament_data expect(response).to have_http_status(:created) expect(response.content_type).to eq('application/json') expect(response.location).to eq(tournament_url(Tournament.last)) @@ -169,16 +200,25 @@ RSpec.describe TournamentsController, type: :controller do context 'with missing teams' do it 'returns an error response' do - data = create_data + data = create_playoff_tournament_data data[:teams] << { id: Team.last.id + 1 } post :create, params: data expect(response).to have_http_status(:not_found) end end + context 'with unequal group sizes' do + it 'returns an error response' do + data = create_group_tournament_data + data[:teams].pop + post :create, params: data + expect(response).to have_http_status(:unprocessable_entity) + end + end + context 'with team names' do it 'creates teams for given names' do - data = create_data + data = create_playoff_tournament_data data.delete :teams data[:teams] = (1..12).collect { { name: Faker::Creature::Dog.name } } expect do @@ -193,6 +233,15 @@ RSpec.describe TournamentsController, type: :controller do expect(response).to have_http_status(:unprocessable_entity) end end + + context 'with empty team objects' do + it 'renders an unprocessable entity response' do + data = create_group_tournament_data + data[:teams] = [{ group: 1 }, { group: 1 }, { group: 2 }, { group: 2 }] + post :create, params: data + expect(response).to have_http_status(:unprocessable_entity) + end + end end end diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index 841f741..839c230 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -2,7 +2,15 @@ FactoryBot.define do factory :group do - number { 0 } + transient do + match_count { 4 } + end + + sequence(:number) stage + + after(:create) do |group, evaluator| + create_list(:group_match, evaluator.match_count, group: group) + end end end diff --git a/spec/factories/matches.rb b/spec/factories/matches.rb index 773151f..3f0a89c 100644 --- a/spec/factories/matches.rb +++ b/spec/factories/matches.rb @@ -16,5 +16,14 @@ FactoryBot.define do factory :group_match, class: Match do group + factory :running_group_match do + transient do + match_scores_count { 2 } + end + after(:create) do |match, evaluator| + match.match_scores = create_list(:match_score, evaluator.match_scores_count) + end + state { 3 } + end end end diff --git a/spec/factories/stages.rb b/spec/factories/stages.rb index 4353af1..e8dce2d 100644 --- a/spec/factories/stages.rb +++ b/spec/factories/stages.rb @@ -3,5 +3,24 @@ FactoryBot.define do factory :stage do tournament + factory :group_stage do + level { -1 } + transient do + group_count { 4 } + end + after(:create) do |stage, evaluator| + stage.groups = create_list(:group, evaluator.group_count) + end + end + + factory :playoff_stage do + level { rand(10) } + transient do + match_count { 4 } + end + after(:create) do |stage, evaluator| + stage.matches = create_list(:match, evaluator.match_count) + end + end end end diff --git a/spec/factories/tournaments.rb b/spec/factories/tournaments.rb index 4b93176..6924332 100644 --- a/spec/factories/tournaments.rb +++ b/spec/factories/tournaments.rb @@ -11,6 +11,7 @@ FactoryBot.define do after(:create) do |tournament, evaluator| tournament.teams = create_list(:team, evaluator.teams_count, tournament: tournament) end + factory :stage_tournament do transient do stage_count { 1 } @@ -19,5 +20,11 @@ FactoryBot.define do tournament.stages = create_list(:stage, evaluator.stage_count) end end + + factory :group_stage_tournament do + after(:create) do |tournament, _evaluator| + tournament.stages = create_list(:group_stage, 1) + end + end end end diff --git a/spec/interactors/add_group_stage_to_tournament_interactor_spec.rb b/spec/interactors/add_group_stage_to_tournament_interactor_spec.rb new file mode 100644 index 0000000..b64c752 --- /dev/null +++ b/spec/interactors/add_group_stage_to_tournament_interactor_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +RSpec.describe AddGroupStageToTournament do + let(:empty_tournament_context) do + AddGroupStageToTournament.call(tournament: @empty_tournament, groups: @groups) + end + + let(:group_stage_tournament_context) do + AddGroupStageToTournament.call(tournament: @group_stage_tournament, groups: @groups) + end + + before do + @empty_tournament = create(:stage_tournament, stage_count: 0) + @group_stage_tournament = create(:group_stage_tournament) + @group_stage = create(:group_stage) + @groups = Hash[1 => create_list(:team, 4), 2 => create_list(:team, 4)].values + end + + context 'GroupStageService mocked' do + before do + expect(class_double('GroupStageService').as_stubbed_const(transfer_nested_constants: true)) + .to receive(:generate_group_stage).with(@groups) + .and_return(@group_stage) + end + + context 'empty tournament' do + it 'succeeds' do + expect(empty_tournament_context).to be_a_success + end + + it 'adds group stage to the tournament' do + test = empty_tournament_context.tournament.stages.first + expect(test).to eq(@group_stage) + end + end + end + + context 'empty groups' do + before do + expect(class_double('GroupStageService').as_stubbed_const(transfer_nested_constants: true)) + .to receive(:generate_group_stage).with(@groups) + .and_raise('Cannot generate group stage without groups') + end + + it 'playoff generation fails' do + expect(empty_tournament_context).to be_a_failure + end + end + + context 'unequal group sizes' do + before do + expect(class_double('GroupStageService').as_stubbed_const(transfer_nested_constants: true)) + .to receive(:generate_group_stage).with(@groups) + .and_raise('Groups need to be equal size') + end + + it 'playoff generation fails' do + expect(empty_tournament_context).to be_a_failure + end + end + + context 'tournament where group stage is already generated' do + it 'does not add group stage' do + expect(group_stage_tournament_context).to be_a_failure + end + end +end diff --git a/spec/interactors/add_playoffs_to_tournament_interactor_spec.rb b/spec/interactors/add_playoffs_to_tournament_interactor_spec.rb index 3bb385d..056dc2b 100644 --- a/spec/interactors/add_playoffs_to_tournament_interactor_spec.rb +++ b/spec/interactors/add_playoffs_to_tournament_interactor_spec.rb @@ -20,7 +20,7 @@ RSpec.describe AddPlayoffsToTournament do @stages = create_list(:stage, 5) end - context 'ez lyfe' do + context 'PlayoffStageService mocked' do before do expect(class_double('PlayoffStageService').as_stubbed_const(transfer_nested_constants: true)) .to receive(:generate_playoff_stages_from_tournament) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 0f5784a..e8c8be2 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -12,4 +12,19 @@ RSpec.describe Group, type: :model do it 'has a valid factory' do expect(build(:group)).to be_valid end + + describe '#teams' do + before do + @group = create(:group, match_count: 1) # this is getting stubbed anyways + @teams = create_list(:team, 4) + expect_any_instance_of(Match) + .to receive(:teams) + .and_return(@teams) + end + + it 'returns all teams from the matches within the matches below' do + teams = @group.teams + expect(teams).to match_array(@teams) + end + end end diff --git a/spec/models/match_spec.rb b/spec/models/match_spec.rb index ad31ac6..01d34c5 100644 --- a/spec/models/match_spec.rb +++ b/spec/models/match_spec.rb @@ -43,7 +43,43 @@ RSpec.describe Match, type: :model do end end + context '#teams' do + before do + @playoff_match = create(:running_playoff_match) + @group_match = create(:running_group_match) + @teams = create_list(:team, 2) + @match_scores = create_list(:match_score, 2) + @match_scores.each_with_index { |match, i| match.team = @teams[i] } + @playoff_match.match_scores = @match_scores + @group_match.match_scores = @match_scores + end + + context 'called on group match' do + let(:call_teams_on_group_match) do + @group_match.teams + end + + it 'returns 2 team objects' do + teams = call_teams_on_group_match + expect(teams).to match_array(@teams) + end + end + + context 'called on playoff match' do + let(:call_teams_on_playoff_match) do + @playoff_match.teams + end + + it 'returns 2 team objects' do + teams = call_teams_on_playoff_match + expect(teams).to match_array(@teams) + end + end + end + it 'has a valid factory' do expect(build(:match)).to be_valid + expect(build(:running_playoff_match)).to be_valid + expect(build(:group_match)).to be_valid end end diff --git a/spec/models/stage_spec.rb b/spec/models/stage_spec.rb index 4af3c30..a14fccd 100644 --- a/spec/models/stage_spec.rb +++ b/spec/models/stage_spec.rb @@ -11,5 +11,38 @@ RSpec.describe Stage, type: :model do it 'has a valid factory' do expect(build(:stage)).to be_valid + expect(build(:group_stage)).to be_valid + end + + describe '#teams' do + context 'group stage' do + before do + @stage = create(:group_stage, group_count: 1) # this is getting stubbed anyways + @teams = create_list(:team, 4) + expect_any_instance_of(Group) + .to receive(:teams) + .and_return(@teams) + end + + it 'returns all teams from the matches within the groups below' do + teams = @stage.teams + expect(teams).to match_array(@teams) + end + end + + context 'playoff stage' do + before do + @stage = create(:playoff_stage, match_count: 1) # this is getting stubbed anyways + @teams = create_list(:team, 4) + expect_any_instance_of(Match) + .to receive(:teams) + .and_return(@teams) + end + + it 'returns all teams from the matches within the matches below' do + teams = @stage.teams + expect(teams).to match_array(@teams) + end + end end end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index ce229fa..d1e477b 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -15,5 +15,6 @@ RSpec.describe Team, type: :model do it 'has a valid factory' do expect(build(:team)).to be_valid + expect(build(:detached_team)).to be_valid end end diff --git a/spec/models/tournament_spec.rb b/spec/models/tournament_spec.rb index be6624a..c437df8 100644 --- a/spec/models/tournament_spec.rb +++ b/spec/models/tournament_spec.rb @@ -33,5 +33,7 @@ RSpec.describe Tournament, type: :model do it 'has valid factory' do expect(build(:tournament)).to be_valid + expect(build(:stage_tournament)).to be_valid + expect(build(:group_stage_tournament)).to be_valid end end diff --git a/spec/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb new file mode 100644 index 0000000..f88f598 --- /dev/null +++ b/spec/services/group_stage_service_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +RSpec.describe GroupStageService do + before do + @stage = create(:stage) + @teams1 = create_list(:team, 4) + @teams2 = create_list(:team, 4) + @groups = Hash[1 => @teams1, 2 => @teams2].values + end + describe '#generate_group_stage method' do + it 'returns a stage object' do + group_stage = GroupStageService.generate_group_stage(@groups) + expect(group_stage).to be_a(Stage) + end + + it 'returns a stage object with level -1' do + group_stage_level = GroupStageService.generate_group_stage(@groups).level + expect(group_stage_level).to be(-1) + end + + it 'adds the provided groups to the stage' do + group_stage_teams = GroupStageService.generate_group_stage(@groups).teams + expect(group_stage_teams).to match_array(@groups.flatten) + end + + it 'raises exception when given different sizes of groups' do + unequal_groups = @groups + unequal_groups.first.pop + expect { GroupStageService.generate_group_stage(unequal_groups) } + .to raise_exception 'Groups need to be equal size' + end + + it 'raises exception when given no groups' do + expect { GroupStageService.generate_group_stage([]) } + .to raise_exception 'Cannot generate group stage without groups' + end + end + + describe '#get_group_object_from' do + it 'returns a group' do + group = GroupStageService.get_group_object_from(@teams1) + expect(group).to be_a(Group) + end + end + + describe '#generate_all_matches_between' do + it 'generates a list of not started matches' do + matches = GroupStageService.generate_all_matches_between(@teams2) + matches.each do |match| + expect(match).to be_a(Match) + match_state = match.state + expect(match_state).to eq('not_started') + end + end + + it 'generates the right amount of matches' do + matches = GroupStageService.generate_all_matches_between(@teams2) + # (1..@teams2.size-1).sum -> 1. Team has to play against n-1 teams; second against n-2 .... + expect(matches.size).to be((1..@teams2.size - 1).sum) + end + + it 'gives matches exclusive positions' do + matches = GroupStageService.generate_all_matches_between(@teams2) + match_positions = matches.map(&:position) + expect(match_positions.length).to eq(match_positions.uniq.length) + end + + it 'doesn\'t match a team against itself' do + matches = GroupStageService.generate_all_matches_between(@teams1) + matches.each do |match| + expect(match.teams.first).to_not eq(match.teams.second) + end + end + end +end