From 7f244817fb5c21af1672e03b44d3446e716eba2d Mon Sep 17 00:00:00 2001 From: Paul DobbinSchmaltz Date: Tue, 12 Nov 2024 01:00:36 -0600 Subject: [PATCH] Add GameTrasactions (Start/End) This allows us to track which User started a Game and which users made the final winning/losing move to end a Game. Also, fix an inconsistency in the test fixtures where the Game with Status "Standing By" had a `started_at` value. --- .../transactions/game_end_transaction.rb | 6 ++ .../transactions/game_start_transaction.rb | 6 ++ app/models/transactions/game_transaction.rb | 48 +++++++++ ...20241112041937_create_game_transactions.rb | 18 ++++ db/structure.sql | 99 +++++++++++++++++++ test/fixtures/game_transactions.yml | 17 ++++ test/fixtures/games.yml | 3 +- test/models/game_test.rb | 5 +- test/models/game_transaction_test.rb | 79 +++++++++++++++ 9 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 app/models/transactions/game_end_transaction.rb create mode 100644 app/models/transactions/game_start_transaction.rb create mode 100644 app/models/transactions/game_transaction.rb create mode 100644 db/migrate/20241112041937_create_game_transactions.rb create mode 100644 test/fixtures/game_transactions.yml create mode 100644 test/models/game_transaction_test.rb diff --git a/app/models/transactions/game_end_transaction.rb b/app/models/transactions/game_end_transaction.rb new file mode 100644 index 00000000..0e974b63 --- /dev/null +++ b/app/models/transactions/game_end_transaction.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# GameEndTransaction is a {GameTransaction} marking which {User} made the final +# move during the associated {Game}. +class GameEndTransaction < GameTransaction +end diff --git a/app/models/transactions/game_start_transaction.rb b/app/models/transactions/game_start_transaction.rb new file mode 100644 index 00000000..eb5a5f28 --- /dev/null +++ b/app/models/transactions/game_start_transaction.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# GameStartTransaction is a {GameTransaction} marking which {User} started the +# associated {Game}. +class GameStartTransaction < GameTransaction +end diff --git a/app/models/transactions/game_transaction.rb b/app/models/transactions/game_transaction.rb new file mode 100644 index 00000000..3a3bae75 --- /dev/null +++ b/app/models/transactions/game_transaction.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# GameTransaction records events transacted by {User}s on a {Game} and when they +# occurred. +# +# @attr type [String] The Subclass name. +# @attr user_id [Integer] References the {User} involved in this Transaction. +# @attr game_id [Integer] References the {Game} involved in this Transaction. +# @attr created_at [DateTime] When this Transaction occurred. +class GameTransaction < ApplicationRecord + self.implicit_order_column = "created_at" + + include AbstractBaseClassBehaviors + include ConsoleBehaviors + + as_abstract_class + + belongs_to :user + belongs_to :game + + scope :for_user, ->(user) { where(user:) } + scope :for_game, ->(game) { where(game:) } + + validates :game, uniqueness: { scope: :type } + + def self.create_between(user:, game:) + new(user:, game:).tap(&:save!) + end + + def self.exists_between?(user:, game:) + for_user(user).for_game(game).exists? + end + + # GameTransaction::Console acts like a {GameTransaction} but otherwise handles + # IRB Console-specific methods/logic. + class Console + include ConsoleObjectBehaviors + + private + + def inspect_info + [ + [user.inspect, game.inspect].join(" -> "), + I18n.l(created_at, format: :debug), + ].join(" @ ") + end + end +end diff --git a/db/migrate/20241112041937_create_game_transactions.rb b/db/migrate/20241112041937_create_game_transactions.rb new file mode 100644 index 00000000..611cd2c5 --- /dev/null +++ b/db/migrate/20241112041937_create_game_transactions.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Version: 20241112041937 +class CreateGameTransactions < ActiveRecord::Migration[8.0] + def change + create_table(:game_transactions) do |t| + t.string(:type, null: false, index: true) + t.references( + :user, type: :uuid, foreign_key: { on_delete: :nullify }, index: true) + t.references( + :game, null: false, foreign_key: { on_delete: :cascade }) + + t.datetime(:created_at, null: false, index: true) + end + + add_index(:game_transactions, %i[game_id type], unique: true) + end +end diff --git a/db/structure.sql b/db/structure.sql index 69e010ab..3ea63af7 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -136,6 +136,38 @@ CREATE SEQUENCE public.cells_id_seq ALTER SEQUENCE public.cells_id_seq OWNED BY public.cells.id; +-- +-- Name: game_transactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.game_transactions ( + id bigint NOT NULL, + type character varying NOT NULL, + user_id uuid, + game_id bigint NOT NULL, + created_at timestamp(6) with time zone NOT NULL +); + + +-- +-- Name: game_transactions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.game_transactions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: game_transactions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.game_transactions_id_seq OWNED BY public.game_transactions.id; + + -- -- Name: games; Type: TABLE; Schema: public; Owner: - -- @@ -250,6 +282,13 @@ ALTER TABLE ONLY public.cell_transactions ALTER COLUMN id SET DEFAULT nextval('p ALTER TABLE ONLY public.cells ALTER COLUMN id SET DEFAULT nextval('public.cells_id_seq'::regclass); +-- +-- Name: game_transactions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.game_transactions ALTER COLUMN id SET DEFAULT nextval('public.game_transactions_id_seq'::regclass); + + -- -- Name: games id; Type: DEFAULT; Schema: public; Owner: - -- @@ -296,6 +335,14 @@ ALTER TABLE ONLY public.cells ADD CONSTRAINT cells_pkey PRIMARY KEY (id); +-- +-- Name: game_transactions game_transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.game_transactions + ADD CONSTRAINT game_transactions_pkey PRIMARY KEY (id); + + -- -- Name: games games_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -426,6 +473,41 @@ CREATE INDEX index_cells_on_mine ON public.cells USING btree (mine); CREATE INDEX index_cells_on_revealed ON public.cells USING btree (revealed); +-- +-- Name: index_game_transactions_on_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_game_transactions_on_created_at ON public.game_transactions USING btree (created_at); + + +-- +-- Name: index_game_transactions_on_game_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_game_transactions_on_game_id ON public.game_transactions USING btree (game_id); + + +-- +-- Name: index_game_transactions_on_game_id_and_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_game_transactions_on_game_id_and_type ON public.game_transactions USING btree (game_id, type); + + +-- +-- Name: index_game_transactions_on_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_game_transactions_on_type ON public.game_transactions USING btree (type); + + +-- +-- Name: index_game_transactions_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_game_transactions_on_user_id ON public.game_transactions USING btree (user_id); + + -- -- Name: index_games_on_bbbv; Type: INDEX; Schema: public; Owner: - -- @@ -526,6 +608,14 @@ ALTER TABLE ONLY public.cell_transactions ADD CONSTRAINT fk_rails_8ae22ea0ff FOREIGN KEY (cell_id) REFERENCES public.cells(id) ON DELETE CASCADE; +-- +-- Name: game_transactions fk_rails_adddf1bd52; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.game_transactions + ADD CONSTRAINT fk_rails_adddf1bd52 FOREIGN KEY (game_id) REFERENCES public.games(id) ON DELETE CASCADE; + + -- -- Name: cell_transactions fk_rails_baaa458a22; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -534,6 +624,14 @@ ALTER TABLE ONLY public.cell_transactions ADD CONSTRAINT fk_rails_baaa458a22 FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL; +-- +-- Name: game_transactions fk_rails_c020c31c6a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.game_transactions + ADD CONSTRAINT fk_rails_c020c31c6a FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL; + + -- -- Name: cells fk_rails_d04db06fd5; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -549,6 +647,7 @@ ALTER TABLE ONLY public.cells SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20241112041937'), ('20240927195322'), ('20240912030247'), ('20240911180310'), diff --git a/test/fixtures/game_transactions.yml b/test/fixtures/game_transactions.yml new file mode 100644 index 00000000..a192ca3f --- /dev/null +++ b/test/fixtures/game_transactions.yml @@ -0,0 +1,17 @@ +win1_game_start_transaction: + type: GameStartTransaction + user: user1 + game: win1 +win1_game_end_transaction: + type: GameEndTransaction + user: user1 + game: win1 + +loss1_game_start_transaction: + type: GameStartTransaction + user: user1 + game: loss1 +loss1_game_end_transaction: + type: GameEndTransaction + user: user2 + game: loss1 diff --git a/test/fixtures/games.yml b/test/fixtures/games.yml index 7ab554f0..1ff4208b 100644 --- a/test/fixtures/games.yml +++ b/test/fixtures/games.yml @@ -11,9 +11,8 @@ loss1: <<: *DEFAULTS status: <%= Game.status_mines_win %> started_at: <%= 30.seconds.ago %> - ended_at: <%= 10.second.ago %> + ended_at: <%= 10.seconds.ago %> standing_by1: <<: *DEFAULTS status: <%= Game.status_standing_by %> - started_at: <%= Time.current %> diff --git a/test/models/game_test.rb b/test/models/game_test.rb index 54d13eb1..511cd85a 100644 --- a/test/models/game_test.rb +++ b/test/models/game_test.rb @@ -27,7 +27,10 @@ class GameTest < ActiveSupport::TestCase context "GIVEN a Game#on? = true Game doesn't exist" do context "GIVEN a recently-ended Game exists" do - before { recently_ended_game.end_in_victory } + before do + recently_ended_game.touch(:started_at) + recently_ended_game.end_in_victory + end let(:recently_ended_game) { standing_by1 } diff --git a/test/models/game_transaction_test.rb b/test/models/game_transaction_test.rb new file mode 100644 index 00000000..b9b916b9 --- /dev/null +++ b/test/models/game_transaction_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "test_helper" + +class GameTransactionTest < ActiveSupport::TestCase + describe "GameTransaction" do + let(:unit_class) { GameTransaction } + + let(:user1) { users(:user1) } + let(:user2) { users(:user2) } + + let(:win1) { games(:win1) } + let(:standing_by1) { games(:standing_by1) } + + describe "DB insertion (GIVEN no Rails validation)" do + subject { GameStartTransaction.new(user: user1, game: win1) } + + it "raises ActiveRecord::RecordNotUnique" do + exception = + _(-> { + subject.save(validate: false) + }).must_raise(ActiveRecord::RecordNotUnique) + + _(exception.message).must_include( + "PG::UniqueViolation: ERROR: "\ + "duplicate key value violates unique constraint "\ + '"index_game_transactions_on_game_id_and_type"') + end + end + + describe ".create_between" do + context "GIVEN a new, unique pair" do + subject { GameStartTransaction } + + it "creates the expected GameTransaction record, and returns it" do + result = + _(-> { + subject.create_between(user: user2, game: standing_by1) + }).must_change("GameTransaction.count") + _(result).must_be_instance_of(subject) + _(result.user).must_be_same_as(user2) + _(result.game).must_be_same_as(standing_by1) + end + end + + context "GIVEN an existing, non-unique pair" do + subject { [GameStartTransaction, GameEndTransaction].sample } + + it "raises ActiveRecord::RecordInvalid" do + exception = + _(-> { + subject.create_between(user: user1, game: win1) + }).must_raise(ActiveRecord::RecordInvalid) + + _(exception.message).must_equal( + "Validation failed: Game has already been taken") + end + end + end + + describe ".exists_between?" do + subject { unit_class } + + context "GIVEN an existing pair" do + it "returns true" do + result = subject.exists_between?(user: user1, game: win1) + _(result).must_equal(true) + end + end + + context "GIVEN a non-existent pair" do + it "returns false" do + result = subject.exists_between?(user: user2, game: win1) + _(result).must_equal(false) + end + end + end + end +end