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