From 5126e94c26c862b8f1a0dc2762a20224093365c5 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 16 Apr 2026 10:23:52 +0200 Subject: [PATCH] Add sync schema contract guardrails --- app/services/tournament_snapshot_builder.rb | 80 ++++++-------------- app/services/tournament_sync_schema.rb | 65 ++++++++++++++++ spec/services/tournament_sync_schema_spec.rb | 18 +++++ 3 files changed, 107 insertions(+), 56 deletions(-) create mode 100644 app/services/tournament_sync_schema.rb create mode 100644 spec/services/tournament_sync_schema_spec.rb diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index c69339f..9ea2363 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true class TournamentSnapshotBuilder + ROOT_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Tournament).map(&:to_sym).freeze + TEAM_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Team).map(&:to_sym).freeze + STAGE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Stage).map(&:to_sym).freeze + GROUP_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Group).map(&:to_sym).freeze + MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).map(&:to_sym).freeze + MATCH_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(MatchScore).map(&:to_sym).freeze + GROUP_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(GroupScore).map(&:to_sym).freeze + def self.build(tournament) new(tournament).build end @@ -10,24 +18,16 @@ class TournamentSnapshotBuilder end def build - { - id: tournament.id, - name: tournament.name, - code: tournament.code, - description: tournament.description, - public: tournament.public, - playoff_teams_amount: tournament.playoff_teams_amount, - instant_finalists_amount: tournament.instant_finalists_amount, - intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, - sync_generated_at: Time.current.iso8601(6), - timer_end: tournament.timer_end&.iso8601, - teams: teams_payload, - stages: stages_payload, - groups: groups_payload, - matches: matches_payload, - match_scores: match_scores_payload, - group_scores: group_scores_payload - } + payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) + payload[:timer_end] = tournament.timer_end&.iso8601 + payload[:sync_generated_at] = Time.current.iso8601(6) + payload[:teams] = teams_payload + payload[:stages] = stages_payload + payload[:groups] = groups_payload + payload[:matches] = matches_payload + payload[:match_scores] = match_scores_payload + payload[:group_scores] = group_scores_payload + payload end private @@ -36,69 +36,37 @@ class TournamentSnapshotBuilder def teams_payload teams_for_snapshot.sort_by(&:id).map do |team| - { - id: team.id, - name: team.name - } + team.attributes.symbolize_keys.slice(*TEAM_ATTRIBUTES) end end def stages_payload tournament.stages.order(:id).map do |stage| - { - id: stage.id, - level: stage.level, - state: stage.state - } + stage.attributes.symbolize_keys.slice(*STAGE_ATTRIBUTES) end end def groups_payload tournament.stages.flat_map(&:groups).sort_by(&:id).map do |group| - { - id: group.id, - stage_id: group.stage_id, - number: group.number - } + group.attributes.symbolize_keys.slice(*GROUP_ATTRIBUTES) end end def matches_payload tournament.matches.sort_by(&:id).map do |match| - { - id: match.id, - stage_id: match.stage&.id, - group_id: match.group&.id, - state: match.state, - position: match.position, - decider: match.decider - } + match.attributes.symbolize_keys.slice(*MATCH_ATTRIBUTES) end end def match_scores_payload tournament.matches.flat_map(&:match_scores).sort_by(&:id).map do |match_score| - { - id: match_score.id, - match_id: match_score.match_id, - team_id: match_score.team_id, - points: match_score.points, - hidden_points: match_score.hidden_points - } + match_score.attributes.symbolize_keys.slice(*MATCH_SCORE_ATTRIBUTES) end end def group_scores_payload tournament.stages.flat_map(&:groups).flat_map(&:group_scores).sort_by(&:id).map do |group_score| - { - id: group_score.id, - group_id: group_score.group_id, - team_id: group_score.team_id, - group_points: group_score.group_points, - scored_points: group_score.scored_points, - received_points: group_score.received_points, - position: group_score.position - } + group_score.attributes.symbolize_keys.slice(*GROUP_SCORE_ATTRIBUTES) end end diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb new file mode 100644 index 0000000..4604a2a --- /dev/null +++ b/app/services/tournament_sync_schema.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class TournamentSyncSchema + MODEL_COLUMNS = { + Tournament => { + synced: %w[ + id + name + code + description + public + playoff_teams_amount + instant_finalists_amount + intermediate_round_participants_amount + timer_end + ].freeze, + ignored: %w[ + user_id + created_at + updated_at + read_only_mode + sync_target_url + sync_auth_token + sync_source_tournament_id + sync_last_pushed_at + sync_last_push_error + sync_last_imported_snapshot_at + ].freeze + }.freeze, + Team => { + synced: %w[id name].freeze, + ignored: %w[tournament_id created_at updated_at sync_source_id].freeze + }.freeze, + Stage => { + synced: %w[id level state].freeze, + ignored: %w[tournament_id created_at updated_at sync_source_id].freeze + }.freeze, + Group => { + synced: %w[id number stage_id].freeze, + ignored: %w[created_at updated_at sync_source_id].freeze + }.freeze, + Match => { + synced: %w[id state position stage_id group_id decider].freeze, + ignored: %w[created_at updated_at sync_source_id].freeze + }.freeze, + MatchScore => { + synced: %w[id points match_id team_id hidden_points].freeze, + ignored: %w[created_at updated_at sync_source_id].freeze + }.freeze, + GroupScore => { + synced: %w[id group_points scored_points received_points team_id group_id position].freeze, + ignored: %w[created_at updated_at sync_source_id].freeze + }.freeze + }.freeze + + class << self + def synced_columns_for(model_class) + MODEL_COLUMNS.fetch(model_class).fetch(:synced) + end + + def ignored_columns_for(model_class) + MODEL_COLUMNS.fetch(model_class).fetch(:ignored) + end + end +end diff --git a/spec/services/tournament_sync_schema_spec.rb b/spec/services/tournament_sync_schema_spec.rb new file mode 100644 index 0000000..de7a8f1 --- /dev/null +++ b/spec/services/tournament_sync_schema_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TournamentSyncSchema do + describe 'column coverage' do + described_class::MODEL_COLUMNS.each_key do |model_class| + it "accounts for every #{model_class.name} column in sync contract" do + synced_columns = described_class.synced_columns_for(model_class) + ignored_columns = described_class.ignored_columns_for(model_class) + accounted_columns = synced_columns + ignored_columns + + expect(accounted_columns.uniq).to match_array(model_class.column_names) + expect(synced_columns & ignored_columns).to be_empty + end + end + end +end