From 9b10cc12d2c73e32c8f22fda1c0cb9e54ccd6f17 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 14:43:20 +0200 Subject: [PATCH 1/7] feat(tournaments): add metadata fields Add typed tournament metadata to API payloads and follower sync. --- app/controllers/tournaments_controller.rb | 1 + app/models/tournament.rb | 18 +++++ .../simple_tournament_serializer.rb | 10 ++- app/services/tournament_snapshot_builder.rb | 2 + app/services/tournament_snapshot_importer.rb | 6 ++ app/services/tournament_sync_schema.rb | 6 ++ ...60504100000_add_metadata_to_tournaments.rb | 14 ++++ db/schema.rb | 8 +- .../tournaments_controller_spec.rb | 79 +++++++++++++++++++ spec/e2e/http/api_surface_spec.rb | 30 +++++++ spec/e2e/http/tournament_follow_sync_spec.rb | 30 +++++++ spec/e2e/http/tournament_sync_test_spec.rb | 14 +++- .../tournament_snapshot_importer_spec.rb | 18 +++++ 13 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260504100000_add_metadata_to_tournaments.rb diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index f878c77..5d05a5f 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -285,6 +285,7 @@ class TournamentsController < ApplicationController params.slice( :name, :description, :public, :teams, :group_stage, :playoff_teams_amount, :instant_finalists_amount, :intermediate_round_participants_amount, + :location, :starts_at, :ends_at, :organizer_name, :contact_email, :website_url, :read_only_mode, :sync_target_url, :sync_auth_token ).permit! end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 4063d27..2b2f2d8 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -6,6 +6,7 @@ require 'openssl' class Tournament < ApplicationRecord TIMER_MODES = %w[countdown countup].freeze TIMER_REASONS = %w[tournament_start playoff_start match_end custom].freeze + METADATA_STRING_ATTRIBUTES = %i[location organizer_name contact_email website_url].freeze belongs_to :user belongs_to :owner, class_name: 'User', foreign_key: :user_id @@ -20,14 +21,18 @@ class Tournament < ApplicationRecord validates :timer_mode, inclusion: { in: TIMER_MODES }, allow_nil: true validates :timer_reason, inclusion: { in: TIMER_REASONS }, allow_nil: true validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true + validates :website_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true + validates :contact_email, format: URI::MailTo::EMAIL_REGEXP, allow_blank: true validate :sync_configuration_is_complete validate :playoff_teams_amount_is_positive_power_of_two validate :timer_reason_text_matches_reason + validate :ends_at_is_after_starts_at after_initialize :generate_code after_create_commit :ensure_default_beamer! before_validation :normalize_timer_reason + before_validation :normalize_metadata before_validation :protect_follower_sync_token before_validation :clear_follower_sync_token_when_disabling_read_only_mode after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state? @@ -154,6 +159,19 @@ class Tournament < ApplicationRecord end end + def normalize_metadata + METADATA_STRING_ATTRIBUTES.each do |attribute| + self[attribute] = self[attribute]&.strip&.presence + end + end + + def ends_at_is_after_starts_at + return if starts_at.blank? || ends_at.blank? + return if ends_at > starts_at + + errors.add(:ends_at, 'must be after starts_at') + end + def saved_change_to_timer_state? saved_change_to_timestamp? || saved_change_to_timer_mode? || diff --git a/app/serializers/simple_tournament_serializer.rb b/app/serializers/simple_tournament_serializer.rb index 0d88639..6bb26e4 100644 --- a/app/serializers/simple_tournament_serializer.rb +++ b/app/serializers/simple_tournament_serializer.rb @@ -1,5 +1,13 @@ # frozen_string_literal: true class SimpleTournamentSerializer < ApplicationSerializer - attributes :name, :code, :public + attributes :name, :code, :public, :location, :organizer_name, :contact_email, :website_url + + attribute :starts_at do + object.starts_at&.iso8601 + end + + attribute :ends_at do + object.ends_at&.iso8601 + end end diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index 0975a78..148c76e 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -22,6 +22,8 @@ class TournamentSnapshotBuilder def build payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) + payload[:starts_at] = tournament.starts_at&.iso8601 + payload[:ends_at] = tournament.ends_at&.iso8601 payload[:timestamp] = tournament.timestamp&.iso8601 payload[:timer_mode] = tournament.timer_mode payload[:timer_reason] = tournament.timer_reason diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index c2868da..0b443f3 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -6,6 +6,10 @@ class TournamentSnapshotImporter code description public + location + organizer_name + contact_email + website_url playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount @@ -67,6 +71,8 @@ class TournamentSnapshotImporter def update_tournament! root_attrs = snapshot.slice(*ROOT_ATTRIBUTES) root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) + root_attrs[:starts_at] = parse_time(snapshot[:starts_at]) + root_attrs[:ends_at] = parse_time(snapshot[:ends_at]) root_attrs[:timestamp] = parse_time(snapshot[:timestamp]) root_attrs[:timer_mode] = snapshot[:timer_mode] root_attrs[:timer_reason] = snapshot[:timer_reason] diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index ee0a9ac..7c9f436 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -9,6 +9,12 @@ class TournamentSyncSchema code description public + location + starts_at + ends_at + organizer_name + contact_email + website_url playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount diff --git a/db/migrate/20260504100000_add_metadata_to_tournaments.rb b/db/migrate/20260504100000_add_metadata_to_tournaments.rb new file mode 100644 index 0000000..5d10c4e --- /dev/null +++ b/db/migrate/20260504100000_add_metadata_to_tournaments.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddMetadataToTournaments < ActiveRecord::Migration[7.0] + def change + change_table :tournaments, bulk: true do |t| + t.string :location + t.datetime :starts_at + t.datetime :ends_at + t.string :organizer_name + t.string :contact_email + t.string :website_url + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 773e654..766e949 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false @@ -163,14 +163,19 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do create_table "tournaments", force: :cascade do |t| t.string "code", null: false + t.string "contact_email" t.datetime "created_at", precision: nil, null: false t.string "description" + t.datetime "ends_at" t.integer "instant_finalists_amount", default: 0 t.integer "intermediate_round_participants_amount", default: 0 + t.string "location" t.string "name", null: false + t.string "organizer_name" t.integer "playoff_teams_amount", default: 0 t.boolean "public", default: true t.boolean "read_only_mode", default: false, null: false + t.datetime "starts_at" t.string "sync_auth_token" t.string "sync_auth_token_digest" t.datetime "sync_last_imported_snapshot_at" @@ -184,6 +189,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do t.datetime "timestamp" t.datetime "updated_at", precision: nil, null: false t.integer "user_id", null: false + t.string "website_url" t.index ["code"], name: "index_tournaments_on_code", unique: true t.index ["user_id"], name: "index_tournaments_on_user_id" end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4f396b3..fdbc7d2 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -105,6 +105,7 @@ RSpec.describe TournamentsController, type: :controller do expected_keys = %i[ id name code public description playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount + location starts_at ends_at organizer_name contact_email website_url timestamp timer_mode timer_reason timer_reason_text owner_username stages teams team_action_lists ] expect(json.keys).to match_array(expected_keys) @@ -407,6 +408,29 @@ RSpec.describe TournamentsController, type: :controller do end.to change(Tournament, :count).by(1) end + it 'stores tournament metadata' do + starts_at = '2030-06-01T10:00:00+02:00' + ends_at = '2030-06-01T18:00:00+02:00' + + post :create, params: create_playoff_tournament_data.merge( + location: 'Stadtpark Halle', + starts_at: starts_at, + ends_at: ends_at, + organizer_name: 'BPWSTR Crew', + contact_email: 'orga@example.com', + website_url: 'https://example.com/tournaments/summer-cup' + ) + + expect(response).to have_http_status(:created) + tournament = Tournament.find(deserialize_response(response)[:id]) + expect(tournament.location).to eq('Stadtpark Halle') + expect(tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601) + expect(tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601) + expect(tournament.organizer_name).to eq('BPWSTR Crew') + expect(tournament.contact_email).to eq('orga@example.com') + expect(tournament.website_url).to eq('https://example.com/tournaments/summer-cup') + end + it 'associates the new tournament with the authenticated user' do expect do post :create, params: create_playoff_tournament_data @@ -587,6 +611,49 @@ RSpec.describe TournamentsController, type: :controller do expect(@tournament.name).to eq(valid_update[:name]) end + it 'updates tournament metadata' do + starts_at = '2030-06-01T10:00:00+02:00' + ends_at = '2030-06-01T18:00:00+02:00' + + put :update, params: { + id: @tournament.to_param, + location: 'Arena Nord', + starts_at: starts_at, + ends_at: ends_at, + organizer_name: 'Turniere Ops', + contact_email: 'ops@example.com', + website_url: 'https://example.com/events/arena-nord' + } + + expect(response).to have_http_status(:ok) + @tournament.reload + json = deserialize_response(response) + expect(@tournament.location).to eq('Arena Nord') + expect(@tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601) + expect(@tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601) + expect(@tournament.organizer_name).to eq('Turniere Ops') + expect(@tournament.contact_email).to eq('ops@example.com') + expect(@tournament.website_url).to eq('https://example.com/events/arena-nord') + expect(json[:starts_at]).to eq(@tournament.starts_at.iso8601) + expect(json[:ends_at]).to eq(@tournament.ends_at.iso8601) + end + + it 'rejects invalid tournament metadata' do + put :update, params: { + id: @tournament.to_param, + starts_at: '2030-06-01T18:00:00+02:00', + ends_at: '2030-06-01T10:00:00+02:00', + contact_email: 'not-an-email', + website_url: 'notaurl' + } + + expect(response).to have_http_status(:unprocessable_content) + json = deserialize_response(response) + expect(json[:ends_at]).to include('must be after starts_at') + expect(json[:contact_email]).to include('is invalid') + expect(json[:website_url]).to include('is invalid') + end + it 'renders a JSON response with the tournament' do put :update, params: { id: @tournament.to_param }.merge(valid_update) expect(response).to have_http_status(:ok) @@ -810,6 +877,12 @@ RSpec.describe TournamentsController, type: :controller do code: 'abc123', description: 'Synced description', public: true, + location: 'Synced Arena', + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + organizer_name: 'Synced Crew', + contact_email: 'sync@example.com', + website_url: 'https://example.com/synced', playoff_teams_amount: 4, instant_finalists_amount: 4, intermediate_round_participants_amount: 0, @@ -827,6 +900,12 @@ RSpec.describe TournamentsController, type: :controller do @tournament.reload expect(@tournament.sync_source_tournament_id).to eq(123) expect(@tournament.name).to eq('Synced Tournament') + expect(@tournament.location).to eq('Synced Arena') + expect(@tournament.starts_at.iso8601).to eq(Time.iso8601('2030-06-01T10:00:00+02:00').utc.iso8601) + expect(@tournament.ends_at.iso8601).to eq(Time.iso8601('2030-06-01T18:00:00+02:00').utc.iso8601) + expect(@tournament.organizer_name).to eq('Synced Crew') + expect(@tournament.contact_email).to eq('sync@example.com') + expect(@tournament.website_url).to eq('https://example.com/synced') expect(@tournament.teams.pluck(:name)).to eq(['Alpha']) expect(deserialize_response(response)).not_to have_key(:sync_auth_token) expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest) diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index f8600d7..a92fbac 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -82,6 +82,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(full_show[:status]).to eq(200) expect(full_show.dig(:json, :stages)).to be_an(Array) expect(full_show.dig(:json, :teams)).to be_an(Array) + expect(full_show.dig(:json, :location)).to start_with('Venue') + expect(full_show.dig(:json, :starts_at)).to eq('2030-06-01T08:00:00Z') + expect(full_show.dig(:json, :ends_at)).to eq('2030-06-01T16:00:00Z') + expect(full_show.dig(:json, :organizer_name)).to eq('Turniere E2E') + expect(full_show.dig(:json, :contact_email)).to eq('events@example.com') + expect(full_show.dig(:json, :website_url)).to eq('https://example.com/turniere-e2e') expect(invalid_index[:status]).to eq(400) expect(invalid_index.dig(:json, :error)).to eq('invalid type') @@ -119,6 +125,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: "Renamed #{unique_suffix}", + location: 'Updated E2E Arena', + starts_at: '2030-07-01T10:00:00+02:00', + ends_at: '2030-07-01T18:00:00+02:00', + organizer_name: 'Updated E2E Org', + contact_email: 'updated-events@example.com', + website_url: 'https://example.com/updated-turniere-e2e', playoff_teams_amount: 8, instant_finalists_amount: 8, intermediate_round_participants_amount: 0 @@ -172,6 +184,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(update_response[:status]).to eq(200) expect(update_response.dig(:json, :name)).to start_with('Renamed') + expect(update_response.dig(:json, :location)).to eq('Updated E2E Arena') + expect(update_response.dig(:json, :starts_at)).to eq('2030-07-01T08:00:00Z') + expect(update_response.dig(:json, :ends_at)).to eq('2030-07-01T16:00:00Z') + expect(update_response.dig(:json, :organizer_name)).to eq('Updated E2E Org') + expect(update_response.dig(:json, :contact_email)).to eq('updated-events@example.com') + expect(update_response.dig(:json, :website_url)).to eq('https://example.com/updated-turniere-e2e') expect(auto_recalc_response[:status]).to eq(200) expect(auto_recalc_response.dig(:json, :playoff_teams_amount)).to eq(4) @@ -511,6 +529,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do name: "#{name_prefix} #{unique_suffix}", description: 'HTTP API surface E2E group-stage tournament', public: public, + location: "Venue #{name_prefix}", + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + organizer_name: 'Turniere E2E', + contact_email: 'events@example.com', + website_url: 'https://example.com/turniere-e2e', group_stage: true, playoff_teams_amount: 8, teams: 4.times.flat_map do |group_index| @@ -567,6 +591,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do name: "#{name_prefix} #{unique_suffix}", description: 'HTTP API surface E2E playoff tournament', public: public, + location: "Venue #{name_prefix}", + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + organizer_name: 'Turniere E2E', + contact_email: 'events@example.com', + website_url: 'https://example.com/turniere-e2e', teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } end diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index aa3f87c..7560aed 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -111,6 +111,12 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do log_phase!('tighten playoff cutoff and rename teams') update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { + location: 'Source Updated Arena', + starts_at: '2030-07-01T10:00:00+02:00', + ends_at: '2030-07-01T18:00:00+02:00', + organizer_name: 'Updated Sync Crew', + contact_email: 'updated-sync@example.com', + website_url: 'https://example.com/updated-sync-source', playoff_teams_amount: 4, instant_finalists_amount: 4, intermediate_round_participants_amount: 0 @@ -233,6 +239,12 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do name: "#{name_prefix} #{unique_suffix}", description: 'Follower sync source tournament', public: public, + location: "Source Venue #{name_prefix}", + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + organizer_name: 'Sync Source Crew', + contact_email: 'sync-source@example.com', + website_url: 'https://example.com/sync-source', group_stage: true, playoff_teams_amount: 8, teams: 4.times.flat_map do |group_index| @@ -255,6 +267,12 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do name: "#{name_prefix} #{unique_suffix}", description: 'Unrelated playoff tournament', public: public, + location: "Noise Venue #{name_prefix}", + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + organizer_name: 'Noise Crew', + contact_email: 'noise@example.com', + website_url: 'https://example.com/noise', teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } end @@ -270,6 +288,12 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do name: "#{name_prefix} #{unique_suffix}", description: 'Follower tournament', public: public, + location: "Follower Venue #{name_prefix}", + starts_at: '2030-05-01T10:00:00+02:00', + ends_at: '2030-05-01T18:00:00+02:00', + organizer_name: 'Follower Crew', + contact_email: 'follower@example.com', + website_url: 'https://example.com/follower', read_only_mode: true, sync_auth_token: sync_token }) @@ -500,6 +524,12 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do { name: tournament.fetch(:name), public: tournament.fetch(:public), + location: tournament[:location], + starts_at: tournament[:starts_at], + ends_at: tournament[:ends_at], + organizer_name: tournament[:organizer_name], + contact_email: tournament[:contact_email], + website_url: tournament[:website_url], playoff_teams_amount: tournament.fetch(:playoff_teams_amount), instant_finalists_amount: tournament.fetch(:instant_finalists_amount), intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount), diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index a1d26f9..8df3498 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -33,7 +33,13 @@ RSpec.describe 'Tournament sync test HTTP E2E' do rename = client.patch("/tournaments/#{leader.fetch(:id)}", body: { name: "Leader Renamed #{SecureRandom.hex(3)}", - description: 'Roundtrip sync test' + description: 'Roundtrip sync test', + location: 'Sync Test Arena', + starts_at: '2030-08-01T10:00:00+02:00', + ends_at: '2030-08-01T18:00:00+02:00', + organizer_name: 'Sync Test Org', + contact_email: 'sync-test@example.com', + website_url: 'https://example.com/sync-test' }) expect(rename[:status]).to eq(200) @@ -52,6 +58,12 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(after_follower.fetch(:name)).to eq(after_leader.fetch(:name)) expect(after_follower.fetch(:description)).to eq(after_leader.fetch(:description)) + expect(after_follower.fetch(:location)).to eq(after_leader.fetch(:location)) + expect(after_follower.fetch(:starts_at)).to eq(after_leader.fetch(:starts_at)) + expect(after_follower.fetch(:ends_at)).to eq(after_leader.fetch(:ends_at)) + expect(after_follower.fetch(:organizer_name)).to eq(after_leader.fetch(:organizer_name)) + expect(after_follower.fetch(:contact_email)).to eq(after_leader.fetch(:contact_email)) + expect(after_follower.fetch(:website_url)).to eq(after_leader.fetch(:website_url)) expect(after_follower.fetch(:teams).map { |team| team.fetch(:name) }) .to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) }) end diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index b86e6fe..f9b563b 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -7,6 +7,12 @@ RSpec.describe TournamentSnapshotImporter do it 'replaces follower tournament graph using source ids as stable mapping keys' do source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) source.update!( + location: 'Court 4', + starts_at: Time.utc(2026, 4, 24, 9, 0, 0), + ends_at: Time.utc(2026, 4, 24, 18, 30, 0), + organizer_name: 'Sync Ops', + contact_email: 'sync-ops@example.com', + website_url: 'https://example.com/sync-cup', timestamp: Time.utc(2026, 4, 24, 10, 15, 0), timer_mode: 'countdown', timer_reason: 'custom', @@ -28,6 +34,12 @@ RSpec.describe TournamentSnapshotImporter do follower.reload expect(follower.sync_source_tournament_id).to eq(source.id) expect(follower.name).to eq(source.name) + expect(follower.location).to eq(source.location) + expect(follower.starts_at).to eq(source.starts_at) + expect(follower.ends_at).to eq(source.ends_at) + expect(follower.organizer_name).to eq(source.organizer_name) + expect(follower.contact_email).to eq(source.contact_email) + expect(follower.website_url).to eq(source.website_url) expect(follower.timer_mode).to eq(source.timer_mode) expect(follower.timer_reason).to eq(source.timer_reason) expect(follower.timer_reason_text).to eq(source.timer_reason_text) @@ -197,6 +209,12 @@ RSpec.describe TournamentSnapshotImporter do { name: tournament.name, public: tournament.public, + location: tournament.location, + starts_at: tournament.starts_at&.iso8601, + ends_at: tournament.ends_at&.iso8601, + organizer_name: tournament.organizer_name, + contact_email: tournament.contact_email, + website_url: tournament.website_url, playoff_teams_amount: tournament.playoff_teams_amount, instant_finalists_amount: tournament.instant_finalists_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, From 794f83514882d6fcb8c5ed7553d500da9f960e9b Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 14:55:48 +0200 Subject: [PATCH 2/7] fix(tournaments): keep owner data out metadata Tournament metadata remains optional and tournament-scoped. --- app/controllers/tournaments_controller.rb | 2 +- app/models/tournament.rb | 3 +-- app/serializers/simple_tournament_serializer.rb | 2 +- app/services/tournament_snapshot_importer.rb | 2 -- app/services/tournament_sync_schema.rb | 2 -- ...20260504100000_add_metadata_to_tournaments.rb | 2 -- db/schema.rb | 2 -- spec/controllers/tournaments_controller_spec.rb | 16 +--------------- spec/e2e/http/api_surface_spec.rb | 10 ---------- spec/e2e/http/tournament_follow_sync_spec.rb | 10 ---------- spec/e2e/http/tournament_sync_test_spec.rb | 4 ---- .../tournament_snapshot_importer_spec.rb | 6 ------ 12 files changed, 4 insertions(+), 57 deletions(-) diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 5d05a5f..d82f58e 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -285,7 +285,7 @@ class TournamentsController < ApplicationController params.slice( :name, :description, :public, :teams, :group_stage, :playoff_teams_amount, :instant_finalists_amount, :intermediate_round_participants_amount, - :location, :starts_at, :ends_at, :organizer_name, :contact_email, :website_url, + :location, :starts_at, :ends_at, :website_url, :read_only_mode, :sync_target_url, :sync_auth_token ).permit! end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 2b2f2d8..6f0ad3a 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -6,7 +6,7 @@ require 'openssl' class Tournament < ApplicationRecord TIMER_MODES = %w[countdown countup].freeze TIMER_REASONS = %w[tournament_start playoff_start match_end custom].freeze - METADATA_STRING_ATTRIBUTES = %i[location organizer_name contact_email website_url].freeze + METADATA_STRING_ATTRIBUTES = %i[location website_url].freeze belongs_to :user belongs_to :owner, class_name: 'User', foreign_key: :user_id @@ -22,7 +22,6 @@ class Tournament < ApplicationRecord validates :timer_reason, inclusion: { in: TIMER_REASONS }, allow_nil: true validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true validates :website_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true - validates :contact_email, format: URI::MailTo::EMAIL_REGEXP, allow_blank: true validate :sync_configuration_is_complete validate :playoff_teams_amount_is_positive_power_of_two diff --git a/app/serializers/simple_tournament_serializer.rb b/app/serializers/simple_tournament_serializer.rb index 6bb26e4..496cbf4 100644 --- a/app/serializers/simple_tournament_serializer.rb +++ b/app/serializers/simple_tournament_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SimpleTournamentSerializer < ApplicationSerializer - attributes :name, :code, :public, :location, :organizer_name, :contact_email, :website_url + attributes :name, :code, :public, :location, :website_url attribute :starts_at do object.starts_at&.iso8601 diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index 0b443f3..db97c91 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -7,8 +7,6 @@ class TournamentSnapshotImporter description public location - organizer_name - contact_email website_url playoff_teams_amount instant_finalists_amount diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index 7c9f436..f395516 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -12,8 +12,6 @@ class TournamentSyncSchema location starts_at ends_at - organizer_name - contact_email website_url playoff_teams_amount instant_finalists_amount diff --git a/db/migrate/20260504100000_add_metadata_to_tournaments.rb b/db/migrate/20260504100000_add_metadata_to_tournaments.rb index 5d10c4e..bcb19f0 100644 --- a/db/migrate/20260504100000_add_metadata_to_tournaments.rb +++ b/db/migrate/20260504100000_add_metadata_to_tournaments.rb @@ -6,8 +6,6 @@ class AddMetadataToTournaments < ActiveRecord::Migration[7.0] t.string :location t.datetime :starts_at t.datetime :ends_at - t.string :organizer_name - t.string :contact_email t.string :website_url end end diff --git a/db/schema.rb b/db/schema.rb index 766e949..4b41a05 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -163,7 +163,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do create_table "tournaments", force: :cascade do |t| t.string "code", null: false - t.string "contact_email" t.datetime "created_at", precision: nil, null: false t.string "description" t.datetime "ends_at" @@ -171,7 +170,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do t.integer "intermediate_round_participants_amount", default: 0 t.string "location" t.string "name", null: false - t.string "organizer_name" t.integer "playoff_teams_amount", default: 0 t.boolean "public", default: true t.boolean "read_only_mode", default: false, null: false diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index fdbc7d2..87b0b2a 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do expected_keys = %i[ id name code public description playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount - location starts_at ends_at organizer_name contact_email website_url + location starts_at ends_at website_url timestamp timer_mode timer_reason timer_reason_text owner_username stages teams team_action_lists ] expect(json.keys).to match_array(expected_keys) @@ -416,8 +416,6 @@ RSpec.describe TournamentsController, type: :controller do location: 'Stadtpark Halle', starts_at: starts_at, ends_at: ends_at, - organizer_name: 'BPWSTR Crew', - contact_email: 'orga@example.com', website_url: 'https://example.com/tournaments/summer-cup' ) @@ -426,8 +424,6 @@ RSpec.describe TournamentsController, type: :controller do expect(tournament.location).to eq('Stadtpark Halle') expect(tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601) expect(tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601) - expect(tournament.organizer_name).to eq('BPWSTR Crew') - expect(tournament.contact_email).to eq('orga@example.com') expect(tournament.website_url).to eq('https://example.com/tournaments/summer-cup') end @@ -620,8 +616,6 @@ RSpec.describe TournamentsController, type: :controller do location: 'Arena Nord', starts_at: starts_at, ends_at: ends_at, - organizer_name: 'Turniere Ops', - contact_email: 'ops@example.com', website_url: 'https://example.com/events/arena-nord' } @@ -631,8 +625,6 @@ RSpec.describe TournamentsController, type: :controller do expect(@tournament.location).to eq('Arena Nord') expect(@tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601) expect(@tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601) - expect(@tournament.organizer_name).to eq('Turniere Ops') - expect(@tournament.contact_email).to eq('ops@example.com') expect(@tournament.website_url).to eq('https://example.com/events/arena-nord') expect(json[:starts_at]).to eq(@tournament.starts_at.iso8601) expect(json[:ends_at]).to eq(@tournament.ends_at.iso8601) @@ -643,14 +635,12 @@ RSpec.describe TournamentsController, type: :controller do id: @tournament.to_param, starts_at: '2030-06-01T18:00:00+02:00', ends_at: '2030-06-01T10:00:00+02:00', - contact_email: 'not-an-email', website_url: 'notaurl' } expect(response).to have_http_status(:unprocessable_content) json = deserialize_response(response) expect(json[:ends_at]).to include('must be after starts_at') - expect(json[:contact_email]).to include('is invalid') expect(json[:website_url]).to include('is invalid') end @@ -880,8 +870,6 @@ RSpec.describe TournamentsController, type: :controller do location: 'Synced Arena', starts_at: '2030-06-01T10:00:00+02:00', ends_at: '2030-06-01T18:00:00+02:00', - organizer_name: 'Synced Crew', - contact_email: 'sync@example.com', website_url: 'https://example.com/synced', playoff_teams_amount: 4, instant_finalists_amount: 4, @@ -903,8 +891,6 @@ RSpec.describe TournamentsController, type: :controller do expect(@tournament.location).to eq('Synced Arena') expect(@tournament.starts_at.iso8601).to eq(Time.iso8601('2030-06-01T10:00:00+02:00').utc.iso8601) expect(@tournament.ends_at.iso8601).to eq(Time.iso8601('2030-06-01T18:00:00+02:00').utc.iso8601) - expect(@tournament.organizer_name).to eq('Synced Crew') - expect(@tournament.contact_email).to eq('sync@example.com') expect(@tournament.website_url).to eq('https://example.com/synced') expect(@tournament.teams.pluck(:name)).to eq(['Alpha']) expect(deserialize_response(response)).not_to have_key(:sync_auth_token) diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index a92fbac..b521e8c 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -85,8 +85,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(full_show.dig(:json, :location)).to start_with('Venue') expect(full_show.dig(:json, :starts_at)).to eq('2030-06-01T08:00:00Z') expect(full_show.dig(:json, :ends_at)).to eq('2030-06-01T16:00:00Z') - expect(full_show.dig(:json, :organizer_name)).to eq('Turniere E2E') - expect(full_show.dig(:json, :contact_email)).to eq('events@example.com') expect(full_show.dig(:json, :website_url)).to eq('https://example.com/turniere-e2e') expect(invalid_index[:status]).to eq(400) @@ -128,8 +126,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do location: 'Updated E2E Arena', starts_at: '2030-07-01T10:00:00+02:00', ends_at: '2030-07-01T18:00:00+02:00', - organizer_name: 'Updated E2E Org', - contact_email: 'updated-events@example.com', website_url: 'https://example.com/updated-turniere-e2e', playoff_teams_amount: 8, instant_finalists_amount: 8, @@ -187,8 +183,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(update_response.dig(:json, :location)).to eq('Updated E2E Arena') expect(update_response.dig(:json, :starts_at)).to eq('2030-07-01T08:00:00Z') expect(update_response.dig(:json, :ends_at)).to eq('2030-07-01T16:00:00Z') - expect(update_response.dig(:json, :organizer_name)).to eq('Updated E2E Org') - expect(update_response.dig(:json, :contact_email)).to eq('updated-events@example.com') expect(update_response.dig(:json, :website_url)).to eq('https://example.com/updated-turniere-e2e') expect(auto_recalc_response[:status]).to eq(200) @@ -532,8 +526,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do location: "Venue #{name_prefix}", starts_at: '2030-06-01T10:00:00+02:00', ends_at: '2030-06-01T18:00:00+02:00', - organizer_name: 'Turniere E2E', - contact_email: 'events@example.com', website_url: 'https://example.com/turniere-e2e', group_stage: true, playoff_teams_amount: 8, @@ -594,8 +586,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do location: "Venue #{name_prefix}", starts_at: '2030-06-01T10:00:00+02:00', ends_at: '2030-06-01T18:00:00+02:00', - organizer_name: 'Turniere E2E', - contact_email: 'events@example.com', website_url: 'https://example.com/turniere-e2e', teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 7560aed..bf27008 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -114,8 +114,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do location: 'Source Updated Arena', starts_at: '2030-07-01T10:00:00+02:00', ends_at: '2030-07-01T18:00:00+02:00', - organizer_name: 'Updated Sync Crew', - contact_email: 'updated-sync@example.com', website_url: 'https://example.com/updated-sync-source', playoff_teams_amount: 4, instant_finalists_amount: 4, @@ -242,8 +240,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do location: "Source Venue #{name_prefix}", starts_at: '2030-06-01T10:00:00+02:00', ends_at: '2030-06-01T18:00:00+02:00', - organizer_name: 'Sync Source Crew', - contact_email: 'sync-source@example.com', website_url: 'https://example.com/sync-source', group_stage: true, playoff_teams_amount: 8, @@ -270,8 +266,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do location: "Noise Venue #{name_prefix}", starts_at: '2030-06-01T10:00:00+02:00', ends_at: '2030-06-01T18:00:00+02:00', - organizer_name: 'Noise Crew', - contact_email: 'noise@example.com', website_url: 'https://example.com/noise', teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } @@ -291,8 +285,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do location: "Follower Venue #{name_prefix}", starts_at: '2030-05-01T10:00:00+02:00', ends_at: '2030-05-01T18:00:00+02:00', - organizer_name: 'Follower Crew', - contact_email: 'follower@example.com', website_url: 'https://example.com/follower', read_only_mode: true, sync_auth_token: sync_token @@ -527,8 +519,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do location: tournament[:location], starts_at: tournament[:starts_at], ends_at: tournament[:ends_at], - organizer_name: tournament[:organizer_name], - contact_email: tournament[:contact_email], website_url: tournament[:website_url], playoff_teams_amount: tournament.fetch(:playoff_teams_amount), instant_finalists_amount: tournament.fetch(:instant_finalists_amount), diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index 8df3498..a3f698b 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -37,8 +37,6 @@ RSpec.describe 'Tournament sync test HTTP E2E' do location: 'Sync Test Arena', starts_at: '2030-08-01T10:00:00+02:00', ends_at: '2030-08-01T18:00:00+02:00', - organizer_name: 'Sync Test Org', - contact_email: 'sync-test@example.com', website_url: 'https://example.com/sync-test' }) expect(rename[:status]).to eq(200) @@ -61,8 +59,6 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(after_follower.fetch(:location)).to eq(after_leader.fetch(:location)) expect(after_follower.fetch(:starts_at)).to eq(after_leader.fetch(:starts_at)) expect(after_follower.fetch(:ends_at)).to eq(after_leader.fetch(:ends_at)) - expect(after_follower.fetch(:organizer_name)).to eq(after_leader.fetch(:organizer_name)) - expect(after_follower.fetch(:contact_email)).to eq(after_leader.fetch(:contact_email)) expect(after_follower.fetch(:website_url)).to eq(after_leader.fetch(:website_url)) expect(after_follower.fetch(:teams).map { |team| team.fetch(:name) }) .to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) }) diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index f9b563b..34e4e6a 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -10,8 +10,6 @@ RSpec.describe TournamentSnapshotImporter do location: 'Court 4', starts_at: Time.utc(2026, 4, 24, 9, 0, 0), ends_at: Time.utc(2026, 4, 24, 18, 30, 0), - organizer_name: 'Sync Ops', - contact_email: 'sync-ops@example.com', website_url: 'https://example.com/sync-cup', timestamp: Time.utc(2026, 4, 24, 10, 15, 0), timer_mode: 'countdown', @@ -37,8 +35,6 @@ RSpec.describe TournamentSnapshotImporter do expect(follower.location).to eq(source.location) expect(follower.starts_at).to eq(source.starts_at) expect(follower.ends_at).to eq(source.ends_at) - expect(follower.organizer_name).to eq(source.organizer_name) - expect(follower.contact_email).to eq(source.contact_email) expect(follower.website_url).to eq(source.website_url) expect(follower.timer_mode).to eq(source.timer_mode) expect(follower.timer_reason).to eq(source.timer_reason) @@ -212,8 +208,6 @@ RSpec.describe TournamentSnapshotImporter do location: tournament.location, starts_at: tournament.starts_at&.iso8601, ends_at: tournament.ends_at&.iso8601, - organizer_name: tournament.organizer_name, - contact_email: tournament.contact_email, website_url: tournament.website_url, playoff_teams_amount: tournament.playoff_teams_amount, instant_finalists_amount: tournament.instant_finalists_amount, From bbaca69265db2af2f4b7d08eb57ff893776650c2 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 15:06:08 +0200 Subject: [PATCH 3/7] feat: allow unplayed playoff deletion --- app/controllers/tournaments_controller.rb | 45 +++++- app/services/tournament_playoff_deletion.rb | 58 +++++++ config/routes.rb | 1 + e2e/lib/scenario_runner.rb | 107 +++++++++++++ script/e2e_scenarios.rb | 2 + .../tournaments_controller_spec.rb | 113 +++++++++++++ spec/e2e/http/tournament_follow_sync_spec.rb | 151 +++++++++++++++++- spec/e2e/http/tournament_lifecycle_spec.rb | 12 ++ spec/routing/tournaments_routing_spec.rb | 4 + tasks.py | 22 +++ 10 files changed, 507 insertions(+), 8 deletions(-) create mode 100644 app/services/tournament_playoff_deletion.rb diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index f878c77..2eb3b7e 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -13,17 +13,17 @@ class TournamentsController < ApplicationController before_action :set_tournament_for_show, only: %i[show] before_action :set_tournament, - only: %i[update destroy set_timer timer sync_state test_sync start_next_group_matches] - before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync start_next_group_matches] + only: %i[update destroy destroy_playoffs set_timer timer sync_state test_sync start_next_group_matches] + before_action :authenticate_user!, only: %i[create update destroy destroy_playoffs set_timer test_sync start_next_group_matches] before_action -> { require_owner! @tournament.owner }, - only: %i[update destroy set_timer test_sync start_next_group_matches] + only: %i[update destroy destroy_playoffs set_timer test_sync start_next_group_matches] before_action :validate_create_params, only: %i[create] before_action :validate_update_params, only: %i[update] before_action :validate_set_timer_params, only: %i[set_timer] before_action :ensure_update_allowed!, only: %i[update] before_action :ensure_destroy_allowed!, only: %i[destroy] before_action :ensure_timer_update_allowed!, only: %i[set_timer] - before_action -> { require_writable_tournament!(@tournament) }, only: %i[start_next_group_matches] + before_action -> { require_writable_tournament!(@tournament) }, only: %i[destroy_playoffs start_next_group_matches] before_action :authorize_sync_request!, only: %i[sync_state] rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error @@ -130,6 +130,30 @@ class TournamentsController < ApplicationController @tournament.destroy end + # DELETE /tournaments/:id/playoffs + def destroy_playoffs + synced_tournament = nil + + Tournament.transaction do + TournamentTransactionLogger.lock_tournament!(@tournament) + @tournament.reload + + result = TournamentPlayoffDeletion.call(tournament: @tournament) + unless result.success? + render json: { error: result.error }, status: :unprocessable_content + raise ActiveRecord::Rollback + end + + synced_tournament = @tournament.reload + render json: playoff_deletion_payload(synced_tournament) + end + + return if synced_tournament.nil? + + push_sync_if_needed!(synced_tournament) + TournamentLiveBroadcast.call(synced_tournament.id) + end + # GET /tournaments/:id/timer def timer render json: serialize_timer_state(@tournament) @@ -222,6 +246,19 @@ class TournamentsController < ApplicationController } end + def playoff_deletion_payload(tournament) + { + id: tournament.id, + stages: tournament.stages.sort_by(&:level).map do |stage| + { + id: stage.id, + level: stage.level, + state: stage.state + } + end + } + 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| diff --git a/app/services/tournament_playoff_deletion.rb b/app/services/tournament_playoff_deletion.rb new file mode 100644 index 0000000..fbeafd8 --- /dev/null +++ b/app/services/tournament_playoff_deletion.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class TournamentPlayoffDeletion + DELETABLE_MATCH_STATES = %w[not_started not_ready single_team].freeze + + Result = Struct.new(:success, :error, keyword_init: true) do + def success? + success + end + end + + def self.call(tournament:) + new(tournament).call + end + + def initialize(tournament) + @tournament = tournament + end + + def call + group_stage = tournament.group_stage + return failure('Only group-stage playoffs can be deleted') if group_stage.nil? + + playoff_stages = tournament.stages.reload.select { |stage| stage.level >= 0 } + return failure('Playoffs have not been generated') if playoff_stages.empty? + unless playoffs_deletable?(playoff_stages) + return failure('Playoffs cannot be deleted after matches have started or scores changed') + end + + playoff_stages.each(&:destroy!) + group_stage.update!(state: :in_progress) + success + end + + private + + attr_reader :tournament + + def playoffs_deletable?(playoff_stages) + playoff_stages.flat_map(&:matches).all? do |match| + DELETABLE_MATCH_STATES.include?(match.state) && zero_scores?(match) + end + end + + def zero_scores?(match) + match.match_scores.all? do |score| + score.points.to_i.zero? && score.hidden_points.to_i.zero? + end + end + + def success + Result.new(success: true) + end + + def failure(error) + Result.new(success: false, error: error) + end +end diff --git a/config/routes.rb b/config/routes.rb index 3176d07..fb289e7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,6 +37,7 @@ Rails.application.routes.draw do patch :sync_state patch :start_next_group_matches post :test_sync + delete :playoffs, action: :destroy_playoffs end end resources :match_scores, only: %i[show update] diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index dace468..b8e20b0 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -109,6 +109,43 @@ module TurniereE2E result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) end + def run_group_stage_playoff_recreation(stop_at: :third_playoffs_created) + session = register_session! + tournament = create_tournament!( + create_group_stage_payload(group_count: 2, teams_per_group: 2, playoff_teams_amount: 2) + ) + + checkpoints = { + created: summarize_tournament(tournament) + } + rounds = playoff_recreation_rounds + created_keys = %i[first_playoffs_created second_playoffs_created third_playoffs_created] + deleted_keys = %i[first_playoffs_deleted second_playoffs_deleted] + + rounds.each_with_index do |round, index| + tournament = fetch_tournament!(tournament.fetch(:id)) + apply_group_score_round!(tournament, round.fetch(:scores_by_team_name)) + finish_stage!(group_stage_of!(fetch_tournament!(tournament.fetch(:id))).fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + + created_key = created_keys.fetch(index) + checkpoints[created_key] = summarize_tournament(tournament).merge( + playoff_team_names: playoff_team_names(tournament) + ) + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == created_key + + next if index == rounds.size - 1 + + delete_playoffs!(tournament.fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + deleted_key = deleted_keys.fetch(index) + checkpoints[deleted_key] = summarize_tournament(tournament) + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == deleted_key + end + + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) + end + def run_playoff_only(team_count:, stop_at: :completed) session = register_session! tournament = create_tournament!(create_playoff_only_payload(team_count: team_count)) @@ -433,6 +470,12 @@ module TurniereE2E response.fetch(:json) end + def delete_playoffs!(tournament_id) + response = client.delete("/tournaments/#{tournament_id}/playoffs") + expect_status!(response, 200, "delete playoffs for tournament #{tournament_id}") + response.fetch(:json) + end + def group_stage_of!(tournament) tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found') end @@ -487,6 +530,34 @@ module TurniereE2E end end + def apply_group_score_round!(tournament, scores_by_team_name) + group_stage_of!(tournament).fetch(:groups).each do |group| + group.fetch(:matches).each do |match| + points_by_team_name = match.fetch(:match_scores).to_h do |score| + team_name = score.dig(:team, :name) + [team_name, scores_by_team_name.fetch(team_name)] + end + + if match.fetch(:state) == 'not_started' + play_match_with_points!(match, points_by_team_name: points_by_team_name) + else + update_match_scores!(match, points_by_team_name: points_by_team_name) + end + end + end + end + + def update_match_scores!(match, points_by_team_name:) + refreshed_match = fetch_match!(match.fetch(:id)) + refreshed_match.fetch(:match_scores).each do |score| + team_name = score.dig(:team, :name) + updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { + points: points_by_team_name.fetch(team_name) + }) + expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}") + end + end + def preferred_playoff_winner_name(match) match.fetch(:match_scores) .map { |score| score.dig(:team, :name) } @@ -521,6 +592,35 @@ module TurniereE2E } end + def playoff_recreation_rounds + [ + { + scores_by_team_name: { + 'G1-S1' => 3, + 'G1-S2' => 1, + 'G2-S1' => 3, + 'G2-S2' => 1 + } + }, + { + scores_by_team_name: { + 'G1-S1' => 0, + 'G1-S2' => 4, + 'G2-S1' => 0, + 'G2-S2' => 4 + } + }, + { + scores_by_team_name: { + 'G1-S1' => 5, + 'G1-S2' => 2, + 'G2-S1' => 2, + 'G2-S2' => 5 + } + } + ] + end + def create_playoff_only_payload(team_count:) { name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}", @@ -611,6 +711,13 @@ module TurniereE2E } end + def playoff_team_names(tournament) + playoff_matches(tournament) + .flat_map { |match| match.fetch(:match_scores).map { |score| score.dig(:team, :name) } } + .uniq + .sort + end + def fetch_tournament_profile!(tournament_id) started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' }) diff --git a/script/e2e_scenarios.rb b/script/e2e_scenarios.rb index fad48d8..45da011 100644 --- a/script/e2e_scenarios.rb +++ b/script/e2e_scenarios.rb @@ -46,6 +46,8 @@ result = case scenario runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted) when 'group_stage_intermediate_round' runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted) + when 'group_stage_playoff_recreation' + runner.run_group_stage_playoff_recreation(stop_at: options[:stop_at] || :third_playoffs_created) when 'playoff_only' runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed) when 'group_stage_render_profile' diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4f396b3..4e14c52 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -794,6 +794,119 @@ RSpec.describe TournamentsController, type: :controller do end end + describe 'DELETE #destroy_playoffs' do + context 'without authentication headers' do + it 'renders an unauthorized error response' do + tournament = create_group_stage_with_unplayed_playoffs + + delete :destroy_playoffs, params: { id: tournament.to_param } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'as owner' do + before do + apply_authentication_headers_for tournament.owner + allow(TournamentLiveBroadcast).to receive(:call) + end + + let(:tournament) { create_group_stage_with_unplayed_playoffs } + + it 'deletes unplayed playoff stages and reopens the group stage' do + expect do + delete :destroy_playoffs, params: { id: tournament.to_param } + end.to change { tournament.reload.stages.where('level >= 0').count }.from(1).to(0) + + expect(response).to have_http_status(:ok) + expect(tournament.group_stage.reload).to be_in_progress + expect(deserialize_response(response).fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1]) + end + + it 'pushes sync and live updates after deletion' do + expect(TournamentSyncEnqueue).to receive(:call).with(tournament) + expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id) + + delete :destroy_playoffs, params: { id: tournament.to_param } + + expect(response).to have_http_status(:ok) + end + + it 'rejects deletion after a playoff match has started' do + playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first + playoff_match.update!(state: :in_progress) + + expect do + delete :destroy_playoffs, params: { id: tournament.to_param } + end.not_to(change { tournament.reload.stages.where('level >= 0').count }) + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq( + 'Playoffs cannot be deleted after matches have started or scores changed' + ) + expect(tournament.group_stage.reload).to be_finished + end + + it 'rejects deletion after a playoff score has changed' do + playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first + playoff_match.match_scores.first.update!(points: 1) + + delete :destroy_playoffs, params: { id: tournament.to_param } + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq( + 'Playoffs cannot be deleted after matches have started or scores changed' + ) + expect(tournament.reload.stages.where('level >= 0').count).to eq(1) + end + + it 'rejects deletion when no group stage exists' do + playoff_only = create(:stage_tournament, stage_count: 1) + apply_authentication_headers_for playoff_only.owner + + delete :destroy_playoffs, params: { id: playoff_only.to_param } + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq('Only group-stage playoffs can be deleted') + end + + it 'blocks deletion while tournament is read only' do + tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret') + + delete :destroy_playoffs, params: { id: tournament.to_param } + + expect(response).to have_http_status(:locked) + end + end + + context 'as another user' do + it 'renders a forbidden error response' do + tournament = create_group_stage_with_unplayed_playoffs + apply_authentication_headers_for create(:user) + + delete :destroy_playoffs, params: { id: tournament.to_param } + + expect(response).to have_http_status(:forbidden) + end + end + + def create_group_stage_with_unplayed_playoffs + tournament = create( + :group_stage_tournament, + stage_count: 0, + group_count: 2, + teams_count: 4, + playoff_teams_amount: 2, + instant_finalists_amount: 2, + intermediate_round_participants_amount: 0 + ) + tournament.group_stage.update!(state: :finished) + result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: tournament.teams.take(2)) + expect(result).to be_a_success + tournament.reload + end + end + describe 'PATCH #sync_state' do before do @tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret') diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index aa3f87c..f54c16c 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -214,6 +214,64 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(takeover_update[:status]).to eq(200) end + it 'mirrors repeated playoff deletion and recreation into the follower tournament' do + log_phase!('create small source and follower tournaments') + source = create_group_stage_tournament( + client: source_owner_client, + public: false, + name_prefix: 'Sync Playoff Delete Source', + group_count: 2, + teams_per_group: 2, + playoff_teams_amount: 2 + ) + follower = create_follower_tournament(client: follower_owner_client, public: true, name_prefix: 'Sync Playoff Delete Follower') + + configure_sync = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { + sync_target_url: sync_state_url(follower.fetch(:id)), + sync_auth_token: sync_token + }) + expect(configure_sync[:status]).to eq(200) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + + rounds = playoff_recreation_rounds + rounds.each_with_index do |round, index| + log_phase!("create playoffs round #{index + 1}") + apply_group_score_round!( + source_tournament_id: source.fetch(:id), + scores_by_team_name: round.fetch(:scores_by_team_name) + ) + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + finish_stage = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) + expect(finish_stage[:status]).to eq(200) + + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(playoff_team_names(source)).to eq(round.fetch(:expected_playoff_team_names)) + expect(playoff_team_names(follower)).to eq(round.fetch(:expected_playoff_team_names)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + + next if index == rounds.size - 1 + + locked_delete = follower_owner_client.delete("/tournaments/#{follower.fetch(:id)}/playoffs") + expect(locked_delete[:status]).to eq(423) + expect(locked_delete.dig(:json, :error)).to eq('Tournament is in read only follower mode') + + log_phase!("delete playoffs round #{index + 1}") + delete_playoffs = source_owner_client.delete("/tournaments/#{source.fetch(:id)}/playoffs") + expect(delete_playoffs[:status]).to eq(200) + + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(source.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1]) + expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1]) + expect(source.fetch(:stages).first.fetch(:state)).to eq('in_progress') + expect(follower.fetch(:stages).first.fetch(:state)).to eq('in_progress') + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + end + end + def log_phase!(message) puts "phase #{message}" $stdout.flush @@ -228,15 +286,18 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do client end - def create_group_stage_tournament(client:, public:, name_prefix:) + def create_group_stage_tournament(client:, public:, name_prefix:, **options) + group_count = options.fetch(:group_count, 4) + teams_per_group = options.fetch(:teams_per_group, 4) + playoff_teams_amount = options.fetch(:playoff_teams_amount, 8) payload = { name: "#{name_prefix} #{unique_suffix}", description: 'Follower sync source tournament', public: public, group_stage: true, - playoff_teams_amount: 8, - teams: 4.times.flat_map do |group_index| - 4.times.map do |seed_index| + playoff_teams_amount: playoff_teams_amount, + teams: group_count.times.flat_map do |group_index| + teams_per_group.times.map do |seed_index| { name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}", group: group_index @@ -250,6 +311,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do fetch_tournament(client: client, tournament_id: response.dig(:json, :id)) end + def sync_state_url(follower_id) + "#{follower_base_url}/tournaments/#{follower_id}/sync_state" + end + def create_playoff_tournament(client:, public:, name_prefix:) payload = { name: "#{name_prefix} #{unique_suffix}", @@ -460,6 +525,84 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do play_match!(match_id: match.fetch(:id), first_score: score_a, second_score: score_b) end + def playoff_recreation_rounds + [ + { + scores_by_team_name: { + 'G1-S1' => 3, + 'G1-S2' => 1, + 'G2-S1' => 3, + 'G2-S2' => 1 + }, + expected_playoff_team_names: %w[G1-S1 G2-S1] + }, + { + scores_by_team_name: { + 'G1-S1' => 0, + 'G1-S2' => 4, + 'G2-S1' => 0, + 'G2-S2' => 4 + }, + expected_playoff_team_names: %w[G1-S2 G2-S2] + }, + { + scores_by_team_name: { + 'G1-S1' => 5, + 'G1-S2' => 2, + 'G2-S1' => 2, + 'G2-S2' => 5 + }, + expected_playoff_team_names: %w[G1-S1 G2-S2] + } + ] + end + + def apply_group_score_round!(source_tournament_id:, scores_by_team_name:) + tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id) + group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + + group_stage.fetch(:groups).each do |group| + group.fetch(:matches).each do |match| + if match.fetch(:state) == 'not_started' + start_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' }) + expect(start_response[:status]).to eq(200) + end + + update_match_points_by_seed!(match.fetch(:id), scores_by_team_name) + + next if match.fetch(:state) == 'finished' + + finish_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' }) + expect(finish_response[:status]).to eq(200) + end + end + end + + def update_match_points_by_seed!(match_id, scores_by_team_name) + match = source_anonymous_client.get("/matches/#{match_id}") + expect(match[:status]).to eq(200) + + match.fetch(:json).fetch(:match_scores).each do |score| + response = source_owner_client.patch("/match_scores/#{score.fetch(:id)}", body: { + points: scores_by_team_name.fetch(seed_label(score.dig(:team, :name))) + }) + expect(response[:status]).to eq(200) + end + end + + def playoff_team_names(tournament) + tournament.fetch(:stages) + .select { |stage| stage.fetch(:level) >= 0 } + .flat_map { |stage| stage.fetch(:matches) } + .flat_map { |match| match.fetch(:match_scores).map { |score| seed_label(score.dig(:team, :name)) } } + .uniq + .sort + end + + def seed_label(team_name) + team_name[/G\d+-S\d+\z/] || team_name + end + def finish_playoff_bracket!(source_id:) loop do tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id) diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index 437c063..22c3970 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -61,6 +61,18 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do .find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished]) end + it 'deletes and recreates group-stage playoffs over HTTP with changed group results' do + result = runner.run_group_stage_playoff_recreation + checkpoints = result.fetch(:checkpoints) + + expect(checkpoints.fetch(:first_playoffs_created).fetch(:playoff_team_names)).to eq(%w[G1-S1 G2-S1]) + expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1]) + expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).first.fetch(:state)).to eq('in_progress') + expect(checkpoints.fetch(:second_playoffs_created).fetch(:playoff_team_names)).to eq(%w[G1-S2 G2-S2]) + expect(checkpoints.fetch(:second_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1]) + expect(checkpoints.fetch(:third_playoffs_created).fetch(:playoff_team_names)).to eq(%w[G1-S1 G2-S2]) + end + { won: [1, 2], lost: [2, 1], diff --git a/spec/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index 0c185eb..77b85bb 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -28,6 +28,10 @@ RSpec.describe TournamentsController, type: :routing do expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1') end + it 'routes to #destroy_playoffs' do + expect(delete: '/tournaments/1/playoffs').to route_to('tournaments#destroy_playoffs', id: '1') + end + it 'routes to #sync_state' do expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1') end diff --git a/tasks.py b/tasks.py index 2e5efe6..e9c986f 100644 --- a/tasks.py +++ b/tasks.py @@ -1041,6 +1041,28 @@ def scenario_intermediate( ) +@task(name="scenario-playoff-recreation") +def scenario_playoff_recreation( + c, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, + stop_at="third_playoffs_created", +): + """Materialize a group-stage playoff delete/recreate scenario over HTTP.""" + c.run( + f"ruby script/e2e_scenarios.rb group_stage_playoff_recreation --stop-at {stop_at}", + env=_env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + ), + pty=True, + ) + + @task(name="scenario-playoff") def scenario_playoff( c, From 0cbcb64066b6db7a2f3ace5c1f610179aa9ba1fa Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 15:10:11 +0200 Subject: [PATCH 4/7] feat(matches): add match metadata updates --- app/controllers/matches_controller.rb | 73 ++++++++++- app/serializers/live_match_serializer.rb | 2 +- app/serializers/match_serializer.rb | 2 +- app/services/tournament_snapshot_importer.rb | 3 + app/services/tournament_sync_schema.rb | 2 +- config/routes.rb | 6 +- ...504100000_add_match_metadata_to_matches.rb | 9 ++ db/schema.rb | 5 +- spec/controllers/matches_controller_spec.rb | 121 +++++++++++++++++- spec/e2e/http/tournament_follow_sync_spec.rb | 62 +++++++++ spec/routing/matches_routing_spec.rb | 4 + spec/services/tournament_live_payload_spec.rb | 7 +- .../tournament_snapshot_importer_spec.rb | 18 +++ tasks.py | 7 +- 14 files changed, 308 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20260504100000_add_match_metadata_to_matches.rb diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 42477ba..2ea5ac3 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -7,9 +7,11 @@ class MatchesController < ApplicationController before_action :set_swap_target, only: %i[swap] before_action -> { require_owner! @match.owner }, only: %i[update swap] before_action -> { require_owner! @group.owner }, only: %i[create] - before_action :set_tournament, only: %i[index] + before_action :set_tournament, only: %i[index metadata] + before_action -> { require_owner! @tournament.owner }, only: %i[metadata] before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update swap] before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create] + before_action -> { require_writable_tournament!(@tournament) }, only: %i[metadata] # GET/tournaments/1/matches def index @@ -63,6 +65,28 @@ class MatchesController < ApplicationController TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? end + # PATCH /tournaments/1/matches/metadata + def metadata + attrs = match_metadata_params + if attrs.empty? + render json: { error: 'At least one metadata field is required' }, status: :bad_request + return + end + + matches = bulk_metadata_matches + return if performed? + + Match.transaction do + TournamentTransactionLogger.lock_tournament!(@tournament) + @tournament.reload + matches.each { |match| match.update!(attrs) } + end + + render json: matches.map(&:reload), each_serializer: LiveMatchSerializer, include: %w[match_scores.team] + push_sync_if_needed!(@tournament) + TournamentLiveBroadcast.call(@tournament.id) + end + # PATCH /matches/1/swap def swap unless @match.swappable_with?(@swap_target) @@ -141,7 +165,7 @@ class MatchesController < ApplicationController old_state = @match.state if @match.update(match_params) - record_successful_match_update(old_state, new_state) + record_successful_match_update(old_state, new_state) unless new_state.nil? render json: @match tournament else @@ -151,6 +175,8 @@ class MatchesController < ApplicationController end def validate_transition_inside_transaction(new_state) + return if new_state.nil? + transition_error = match_transition_error(@match, new_state) return if transition_error.nil? @@ -175,6 +201,8 @@ class MatchesController < ApplicationController end def validate_params + return unless params.key?(:state) + transition_error = match_transition_error(@match, match_params['state']) return if transition_error.nil? @@ -201,13 +229,52 @@ class MatchesController < ApplicationController end def match_params - params.slice(:state).permit! + params.slice(:state, :location, :start_time, :notes).permit! end def match_index_params params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit! end + def match_metadata_params + params.slice(:location, :start_time, :notes).permit! + end + + def bulk_metadata_matches + return bulk_metadata_matches_by_id if params.key?(:match_ids) + + return @tournament.matches if ActiveModel::Type::Boolean.new.cast(params[:all]) + return filtered_bulk_metadata_matches if match_index_params.values.any?(&:present?) + + render json: { error: 'Select matches with match_ids, filters, or all=true' }, status: :bad_request + [] + end + + def bulk_metadata_matches_by_id + ids = parse_match_ids + return [] if performed? + + matches = @tournament.matches.select { |match| ids.include?(match.id) } + missing_ids = ids - matches.map(&:id) + if missing_ids.any? + render json: { error: "Matches not found in tournament: #{missing_ids.join(', ')}" }, status: :not_found + return [] + end + + ids.map { |id| matches.find { |match| match.id == id } } + end + + def filtered_bulk_metadata_matches + TournamentMatchesPayload.matches_for(@tournament, match_index_params) + end + + def parse_match_ids + Array(params[:match_ids]).map { |id| Integer(id.to_s, 10) }.uniq + rescue ArgumentError, TypeError + render json: { error: 'match_ids must contain only integer ids' }, status: :bad_request + [] + end + def create_match_params params.slice(:team_ids).permit(team_ids: []) end diff --git a/app/serializers/live_match_serializer.rb b/app/serializers/live_match_serializer.rb index 3b0f27a..a91295a 100644 --- a/app/serializers/live_match_serializer.rb +++ b/app/serializers/live_match_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class LiveMatchSerializer < ApplicationSerializer - attributes :position, :state, :teams, :group, :stage + attributes :position, :state, :location, :start_time, :notes, :teams, :group, :stage has_many :match_scores diff --git a/app/serializers/match_serializer.rb b/app/serializers/match_serializer.rb index 50feade..3479d5a 100644 --- a/app/serializers/match_serializer.rb +++ b/app/serializers/match_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class MatchSerializer < ApplicationSerializer - attributes :state, :position, :winner, :decider + attributes :state, :position, :winner, :decider, :location, :start_time, :notes def winner ActiveModelSerializers::SerializableResource.new(object.winner).as_json diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index c2868da..5ddf768 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -139,6 +139,9 @@ class TournamentSnapshotImporter match.state = attrs.fetch(:state) match.position = attrs[:position] match.decider = attrs[:decider] + match.location = attrs[:location] + match.start_time = attrs[:start_time] + match.notes = attrs[:notes] end end diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index ee0a9ac..b4102ef 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -48,7 +48,7 @@ class TournamentSyncSchema ignored: %w[created_at updated_at sync_source_id].freeze }.freeze, Match => { - synced: %w[id state position stage_id group_id decider].freeze, + synced: %w[id state position stage_id group_id decider location start_time notes].freeze, ignored: %w[created_at updated_at sync_source_id].freeze }.freeze, MatchScore => { diff --git a/config/routes.rb b/config/routes.rb index 3176d07..ba25f79 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,7 +24,11 @@ Rails.application.routes.draw do resources :team_action_lists, only: %i[create] patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' resources :statistics, only: %i[index] - resources :matches, only: %i[index] + resources :matches, only: %i[index] do + collection do + patch :metadata + end + end resources :beamers, only: %i[index show create update destroy] do collection do get :by_name diff --git a/db/migrate/20260504100000_add_match_metadata_to_matches.rb b/db/migrate/20260504100000_add_match_metadata_to_matches.rb new file mode 100644 index 0000000..4a7b131 --- /dev/null +++ b/db/migrate/20260504100000_add_match_metadata_to_matches.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddMatchMetadataToMatches < ActiveRecord::Migration[7.0] + def change + add_column :matches, :location, :string + add_column :matches, :start_time, :string + add_column :matches, :notes, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index 773e654..d700640 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false @@ -74,8 +74,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do t.datetime "created_at", precision: nil, null: false t.boolean "decider", default: false, null: false t.integer "group_id" + t.string "location" + t.text "notes" t.integer "position" t.integer "stage_id" + t.string "start_time" t.integer "state", default: 0 t.integer "sync_source_id" t.datetime "updated_at", precision: nil, null: false diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index 60c92a2..3835f84 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -47,7 +47,7 @@ RSpec.describe MatchesController, type: :controller do get :index, params: { tournament_id: @tournament.to_param } match = deserialize_response(response).first - expect(match.keys).to match_array(%i[id position state teams match_scores group stage]) + expect(match.keys).to match_array(%i[id position state location start_time notes teams match_scores group stage]) expect(match[:teams]).to all(include(:id, :name)) end @@ -196,6 +196,102 @@ RSpec.describe MatchesController, type: :controller do end end + describe 'PATCH #metadata' do + let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) } + + context 'as owner' do + before do + apply_authentication_headers_for group_stage_tournament.owner + allow(TournamentSyncEnqueue).to receive(:call) + allow(TournamentLiveBroadcast).to receive(:call) + end + + it 'updates selected matches by explicit ids' do + selected_matches = group_stage_tournament.matches.first(2) + + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + match_ids: selected_matches.map(&:id), + location: 'Court A', + start_time: '17:00', + notes: 'Bring player cards' + } + + expect(response).to be_successful + expect(selected_matches.map { |match| match.reload.location }).to all(eq('Court A')) + expect(selected_matches.map { |match| match.reload.start_time }).to all(eq('17:00')) + expect(selected_matches.map { |match| match.reload.notes }).to all(eq('Bring player cards')) + expect(deserialize_response(response).map { |match| match[:id] }).to eq(selected_matches.map(&:id)) + expect(TournamentSyncEnqueue).to have_received(:call).with(group_stage_tournament) + expect(TournamentLiveBroadcast).to have_received(:call).with(group_stage_tournament.id) + end + + it 'updates group stage matches by position filter' do + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + group_stage_position: 1, + start_time: '18:00' + } + + expect(response).to be_successful + group_stage_tournament.matches.each do |match| + expected_start_time = match.group_match? && match.position == 1 ? '18:00' : nil + expect(match.reload.start_time).to eq(expected_start_time) + end + end + + it 'updates playoff matches by stage level filter' do + playoff_tournament = create(:stage_tournament, stage_count: 3, user: group_stage_tournament.owner) + target_stage = playoff_tournament.stages.find_by!(level: 3) + + patch :metadata, params: { + tournament_id: playoff_tournament.to_param, + stage_level: target_stage.level, + start_time: '21:00' + } + + expect(response).to be_successful + playoff_tournament.matches.each do |match| + expected_start_time = match.stage == target_stage ? '21:00' : nil + expect(match.reload.start_time).to eq(expected_start_time) + end + end + + it 'requires a selector' do + patch :metadata, params: { tournament_id: group_stage_tournament.to_param, start_time: '17:00' } + + expect(response).to have_http_status(:bad_request) + expect(deserialize_response(response)[:error]).to eq('Select matches with match_ids, filters, or all=true') + end + + it 'rejects matches outside the tournament' do + other_match = create(:match) + + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + match_ids: [other_match.id], + start_time: '17:00' + } + + expect(response).to have_http_status(:not_found) + expect(deserialize_response(response)[:error]).to eq("Matches not found in tournament: #{other_match.id}") + end + + it 'rejects read only follower tournaments' do + group_stage_tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret') + + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + match_ids: [group_stage_tournament.matches.first.id], + start_time: '17:00' + } + + expect(response).to have_http_status(:locked) + expect(deserialize_response(response)[:error]).to eq('Tournament is in read only follower mode') + end + end + end + describe 'POST #update' do context 'on a running playoff match' do let(:valid_update) do @@ -237,6 +333,29 @@ RSpec.describe MatchesController, type: :controller do expect(body[:state]).to eq(valid_update[:state]) end + it 'updates metadata without changing match state' do + expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) + + put :update, params: { + id: @match.to_param, + location: 'Main Hall', + start_time: '17:00', + notes: 'Stream setup needed' + } + + expect(response).to be_successful + @match.reload + expect(@match.state).to eq('not_started') + expect(@match.location).to eq('Main Hall') + expect(@match.start_time).to eq('17:00') + expect(@match.notes).to eq('Stream setup needed') + expect(deserialize_response(response)).to include( + location: 'Main Hall', + start_time: '17:00', + notes: 'Stream setup needed' + ) + end + it 'writes a transaction log entry for the state change' do expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index aa3f87c..8ce75a2 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -50,6 +50,51 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty + log_phase!('update match metadata and verify follower mirrors it') + source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first + first_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) } + single_metadata = source_owner_client.patch("/matches/#{first_group_matches.fetch(0).fetch(:id)}", body: { + location: 'Main Hall', + start_time: '17:00', + notes: 'Opening match' + }) + explicit_bulk_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: { + match_ids: first_group_matches.values_at(1, 2).map { |match| match.fetch(:id) }, + location: 'Court B', + start_time: '17:30' + }) + group_position_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: { + group_stage_position: 3, + start_time: '18:00', + notes: 'Group position 3 wave' + }) + expect(single_metadata[:status]).to eq(200) + expect(single_metadata.dig(:json, :location)).to eq('Main Hall') + expect(explicit_bulk_metadata[:status]).to eq(200) + expect(explicit_bulk_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Court B')) + expect(group_position_metadata[:status]).to eq(200) + expect(group_position_metadata[:json].map { |match| match.fetch(:start_time) }).to all(eq('18:00')) + + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + + follower_group_stage = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + follower_group = follower_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first + follower_first_match = follower_group.fetch(:matches).sort_by { |match| match.fetch(:position) }.first + locked_match_metadata = follower_owner_client.patch("/matches/#{follower_first_match.fetch(:id)}", body: { + location: 'Follower edit' + }) + locked_bulk_metadata = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}/matches/metadata", body: { + match_ids: [follower_first_match.fetch(:id)], + start_time: '19:00' + }) + expect(locked_match_metadata[:status]).to eq(423) + expect(locked_match_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode') + expect(locked_bulk_metadata[:status]).to eq(423) + expect(locked_bulk_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode') + log_phase!('swap matches on leader and verify follower behavior') source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first @@ -144,6 +189,20 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(source.fetch(:team_action_lists)).to eq([]) expect(follower.fetch(:team_action_lists)).to eq([]) + log_phase!('bulk update playoff match metadata and verify follower mirrors it') + source_playoff_stage = source.fetch(:stages).select { |stage| stage.fetch(:level) >= 0 }.max_by { |stage| stage.fetch(:level) } + playoff_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: { + stage_level: source_playoff_stage.fetch(:level), + location: 'Finals Arena', + start_time: '21:00', + notes: 'Playoff wave' + }) + expect(playoff_metadata[:status]).to eq(200) + expect(playoff_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Finals Arena')) + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + log_phase!('create team action list on source and verify follower mirrors it') created_list = create_team_action_list!(source.fetch(:id)) expect(created_list.fetch(:key)).to eq(playoff_token_list_key) @@ -548,6 +607,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do state: match.fetch(:state), position: match.fetch(:position), decider: match.fetch(:decider), + location: match[:location], + start_time: match[:start_time], + notes: match[:notes], scores: match.fetch(:match_scores).map do |score| { team_name: score.dig(:team, :name), diff --git a/spec/routing/matches_routing_spec.rb b/spec/routing/matches_routing_spec.rb index ac3a659..b832c37 100644 --- a/spec/routing/matches_routing_spec.rb +++ b/spec/routing/matches_routing_spec.rb @@ -15,5 +15,9 @@ RSpec.describe MatchesController, type: :routing do it 'routes to #swap' do expect(patch: '/matches/1/swap').to route_to('matches#swap', id: '1') end + + it 'routes nested tournament match metadata updates' do + expect(patch: '/tournaments/1/matches/metadata').to route_to('matches#metadata', tournament_id: '1') + end end end diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index 8e5781d..7cb435f 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -26,9 +26,14 @@ RSpec.describe 'Tournament live payloads' do payload = described_class.build(tournament) serialized_match = payload.find { |candidate| candidate[:id] == match.id } - expect(serialized_match.keys).to match_array(%i[id position state teams match_scores group stage]) + expect(serialized_match.keys).to match_array( + %i[id position state location start_time notes teams match_scores group stage] + ) expect(serialized_match[:state]).to eq(match.state) expect(serialized_match[:position]).to eq(match.position) + expect(serialized_match[:location]).to eq(match.location) + expect(serialized_match[:start_time]).to eq(match.start_time) + expect(serialized_match[:notes]).to eq(match.notes) expect(serialized_match.dig(:group, :id)).to eq(match.group_id) expect(serialized_match.dig(:stage, :id)).to eq(match.group.stage_id) expect(serialized_match[:teams].map { |team| team[:id] }).to match_array(match.teams.map(&:id)) diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index b86e6fe..9947d4c 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -12,6 +12,11 @@ RSpec.describe TournamentSnapshotImporter do timer_reason: 'custom', timer_reason_text: 'Opening ceremony' ) + source.matches.first.update!( + location: 'Main Hall', + start_time: '17:00', + notes: 'Opening match' + ) source.beamers.create!( name: 'stream-a', display_state: 'during_game', @@ -37,6 +42,11 @@ RSpec.describe TournamentSnapshotImporter do expect(follower.team_action_lists.first.team_action_items.count).to eq(snapshot.fetch(:team_action_items).count) expect(follower.stages.count).to eq(source.stages.count) expect(follower.matches.count).to eq(source.matches.count) + expect(follower.matches.find { |match| match.sync_source_id == source.matches.first.id }).to have_attributes( + location: 'Main Hall', + start_time: '17:00', + notes: 'Opening match' + ) expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) }) expect(follower.beamers.pluck(:sync_source_id)).to match_array(snapshot.fetch(:beamers).map { |beamer| beamer.fetch(:id) }) end @@ -136,6 +146,11 @@ RSpec.describe TournamentSnapshotImporter do tournament.reload play_some_playoff_matches!(tournament) + tournament.matches.min_by(&:id).update!( + location: 'Feature Court', + start_time: '18:30', + notes: 'Follower sync metadata fixture' + ) tournament.update!( timestamp: Time.utc(2026, 4, 24, 10, 15, 0), timer_mode: 'countdown', @@ -254,6 +269,9 @@ RSpec.describe TournamentSnapshotImporter do state: match.state, position: match.position, decider: match.decider, + location: match.location, + start_time: match.start_time, + notes: match.notes, scores: match.match_scores.includes(:team).sort_by { |score| score.team.name }.map do |score| { team_name: score.team.name, diff --git a/tasks.py b/tasks.py index 2e5efe6..e598c43 100644 --- a/tasks.py +++ b/tasks.py @@ -751,9 +751,10 @@ def start_test_local( @task -def test(c): - """Run the normal RSpec suite.""" - _stream_subprocess(_rspec_command(), env=_env()) +def test(c, spec=""): + """Run the normal RSpec suite, or one or more spec files via --spec.""" + spec_paths = shlex.split(spec) if spec else [] + _stream_subprocess(_rspec_command(*spec_paths), env=_env()) @task(name="test-shard") From 9c006e57d2f3d07627089214cb05926f76fb5465 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 17:41:13 +0200 Subject: [PATCH 5/7] docs(matches): document metadata API --- doc/match_metadata_api.md | 86 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 doc/match_metadata_api.md diff --git a/doc/match_metadata_api.md b/doc/match_metadata_api.md new file mode 100644 index 0000000..f516af8 --- /dev/null +++ b/doc/match_metadata_api.md @@ -0,0 +1,86 @@ +# Match Metadata API + +Matches can store optional scheduling/display metadata: + +- `location`: string, for venue, court, stream, or table +- `start_time`: string, for a local time like `17:00` or an agreed display value +- `notes`: free text for one-off match information + +All fields are optional and nullable. Omitted fields are left unchanged. + +## Update One Match + +Use `PATCH /matches/:id` to update metadata on one match. This endpoint still +accepts the existing `state` transitions, and metadata can be sent with or +without `state`. + +```json +{ + "location": "Main Hall", + "start_time": "17:00", + "notes": "Opening match. Stream setup needed." +} +``` + +Owner authentication is required. Read-only follower tournaments reject this +with `423 Locked`. + +## Bulk Update Matches + +Use `PATCH /tournaments/:tournament_id/matches/metadata` to update metadata for +multiple matches in one tournament. + +At least one metadata field is required: + +```json +{ + "start_time": "18:00" +} +``` + +At least one selector is required: + +- `match_ids`: explicit match ids in this tournament +- `group_stage_position`: all group stage matches at that match position +- `stage_level`: all playoff matches in that stage level +- `stage_id`: all matches in one stage +- `position`: all matches with that position +- `state`: all matches in that state, including `upcoming` +- `all: true`: every match in the tournament + +Explicit ids take precedence over filters. Missing ids outside the tournament +return `404 Not Found`. + +Examples: + +```json +{ + "match_ids": [1, 2, 5], + "start_time": "17:00" +} +``` + +```json +{ + "group_stage_position": 1, + "start_time": "18:00" +} +``` + +```json +{ + "stage_level": 3, + "location": "Finals Arena", + "start_time": "21:00", + "notes": "Playoff wave" +} +``` + +Owner authentication is required. Read-only follower tournaments reject this +with `423 Locked`. + +## Follower Sync + +`location`, `start_time`, and `notes` are part of tournament sync snapshots. +Leader updates enqueue follower sync and live tournament broadcasts. Followers +import these fields from the leader and remain read-only until takeover. From bcc93099bf230883dc12bb53d1b21f72181cd60a Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 17:42:38 +0200 Subject: [PATCH 6/7] test: assert playoff recreation matchups --- e2e/lib/scenario_runner.rb | 18 ++++++---- spec/e2e/http/tournament_follow_sync_spec.rb | 37 +++++++++++++------- spec/e2e/http/tournament_lifecycle_spec.rb | 15 ++++++-- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index b8e20b0..cee171a 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -130,7 +130,7 @@ module TurniereE2E created_key = created_keys.fetch(index) checkpoints[created_key] = summarize_tournament(tournament).merge( - playoff_team_names: playoff_team_names(tournament) + playoff_matchups: playoff_matchups(tournament) ) return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == created_key @@ -711,11 +711,17 @@ module TurniereE2E } end - def playoff_team_names(tournament) - playoff_matches(tournament) - .flat_map { |match| match.fetch(:match_scores).map { |score| score.dig(:team, :name) } } - .uniq - .sort + def playoff_matchups(tournament) + entry_stage = tournament.fetch(:stages) + .reject { |stage| stage.fetch(:level) == -1 } + .max_by { |stage| stage.fetch(:level) } + entry_stage.fetch(:matches) + .sort_by { |match| match.fetch(:position) } + .map do |match| + match.fetch(:match_scores) + .map { |score| score.dig(:team, :name) } + .sort + end end def fetch_tournament_profile!(tournament_id) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index f54c16c..410bba8 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -248,8 +248,8 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) - expect(playoff_team_names(source)).to eq(round.fetch(:expected_playoff_team_names)) - expect(playoff_team_names(follower)).to eq(round.fetch(:expected_playoff_team_names)) + expect(playoff_matchups(source)).to eq(round.fetch(:expected_playoff_matchups)) + expect(playoff_matchups(follower)).to eq(round.fetch(:expected_playoff_matchups)) expect(tournament_signature(follower)).to eq(tournament_signature(source)) next if index == rounds.size - 1 @@ -534,7 +534,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 'G2-S1' => 3, 'G2-S2' => 1 }, - expected_playoff_team_names: %w[G1-S1 G2-S1] + expected_playoff_matchups: [ + %w[G1-S1 G2-S2], + %w[G1-S2 G2-S1] + ] }, { scores_by_team_name: { @@ -543,7 +546,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 'G2-S1' => 0, 'G2-S2' => 4 }, - expected_playoff_team_names: %w[G1-S2 G2-S2] + expected_playoff_matchups: [ + %w[G1-S2 G2-S1], + %w[G1-S1 G2-S2] + ] }, { scores_by_team_name: { @@ -552,7 +558,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 'G2-S1' => 2, 'G2-S2' => 5 }, - expected_playoff_team_names: %w[G1-S1 G2-S2] + expected_playoff_matchups: [ + %w[G1-S1 G2-S1], + %w[G1-S2 G2-S2] + ] } ] end @@ -590,13 +599,17 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do end end - def playoff_team_names(tournament) - tournament.fetch(:stages) - .select { |stage| stage.fetch(:level) >= 0 } - .flat_map { |stage| stage.fetch(:matches) } - .flat_map { |match| match.fetch(:match_scores).map { |score| seed_label(score.dig(:team, :name)) } } - .uniq - .sort + def playoff_matchups(tournament) + entry_stage = tournament.fetch(:stages) + .select { |stage| stage.fetch(:level) >= 0 } + .max_by { |stage| stage.fetch(:level) } + entry_stage.fetch(:matches) + .sort_by { |match| match.fetch(:position) } + .map do |match| + match.fetch(:match_scores) + .map { |score| seed_label(score.dig(:team, :name)) } + .sort + end end def seed_label(team_name) diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index 22c3970..ea5ff0a 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -65,12 +65,21 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do result = runner.run_group_stage_playoff_recreation checkpoints = result.fetch(:checkpoints) - expect(checkpoints.fetch(:first_playoffs_created).fetch(:playoff_team_names)).to eq(%w[G1-S1 G2-S1]) + expect(checkpoints.fetch(:first_playoffs_created).fetch(:playoff_matchups)).to eq([ + %w[G1-S1 G2-S2], + %w[G1-S2 G2-S1] + ]) expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1]) expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).first.fetch(:state)).to eq('in_progress') - expect(checkpoints.fetch(:second_playoffs_created).fetch(:playoff_team_names)).to eq(%w[G1-S2 G2-S2]) + expect(checkpoints.fetch(:second_playoffs_created).fetch(:playoff_matchups)).to eq([ + %w[G1-S2 G2-S1], + %w[G1-S1 G2-S2] + ]) expect(checkpoints.fetch(:second_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1]) - expect(checkpoints.fetch(:third_playoffs_created).fetch(:playoff_team_names)).to eq(%w[G1-S1 G2-S2]) + expect(checkpoints.fetch(:third_playoffs_created).fetch(:playoff_matchups)).to eq([ + %w[G1-S1 G2-S1], + %w[G1-S2 G2-S2] + ]) end { From 0d466649957fbae6850c2584c0939753ee97d35b Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 17:47:12 +0200 Subject: [PATCH 7/7] fix(db): avoid duplicate migration version --- ...tches.rb => 20260504101000_add_match_metadata_to_matches.rb} | 0 db/schema.rb | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename db/migrate/{20260504100000_add_match_metadata_to_matches.rb => 20260504101000_add_match_metadata_to_matches.rb} (100%) diff --git a/db/migrate/20260504100000_add_match_metadata_to_matches.rb b/db/migrate/20260504101000_add_match_metadata_to_matches.rb similarity index 100% rename from db/migrate/20260504100000_add_match_metadata_to_matches.rb rename to db/migrate/20260504101000_add_match_metadata_to_matches.rb diff --git a/db/schema.rb b/db/schema.rb index 696dc12..46f5577 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_04_101000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false