From 4b4dddc14873cc0ef259789651550a0a23def87b Mon Sep 17 00:00:00 2001 From: Ngan Pham Date: Wed, 4 Mar 2026 20:40:15 -1000 Subject: [PATCH 1/4] Use unscoped when finding exposed records Ensures exposed records can always be found regardless of any default_scope on the model. For example, if a model uses soft-deletes with a default_scope filtering out deleted records, the fixture helper should still return the record since it was explicitly exposed. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/fixture_kit/repository.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fixture_kit/repository.rb b/lib/fixture_kit/repository.rb index 12a6c7d..ca69ddc 100644 --- a/lib/fixture_kit/repository.rb +++ b/lib/fixture_kit/repository.rb @@ -33,7 +33,7 @@ def materialize(value) end def load_record(record_info) - record_info.keys.first.find_by(id: record_info.values.first) + record_info.keys.first.unscoped.find_by(id: record_info.values.first) end end end From 35e0348191e4e1f3c518934222a524aee9934bd1 Mon Sep 17 00:00:00 2001 From: Ngan Pham Date: Wed, 4 Mar 2026 20:44:31 -1000 Subject: [PATCH 2/4] Add integration test for unscoped record lookup Adds a soft-delete scenario to the dummy app to verify that exposed records hidden by a default_scope are still accessible via the fixture helper. Updates the existing unit test mocks to reflect the new unscoped call chain. Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/dummy/app/models/project.rb | 2 ++ .../20240101000003_add_deleted_at_to_projects.rb | 7 +++++++ spec/dummy/db/schema.rb | 3 ++- spec/dummy/fixture_kit/soft_deleted_project.rb | 16 ++++++++++++++++ .../spec/integration/fixture_kit_integration.rb | 14 ++++++++++++++ spec/dummy/spec/rails_helper.rb | 1 + .../integration/fixture_kit_integration_test.rb | 14 ++++++++++++++ spec/dummy/test/test_helper.rb | 1 + spec/integration/dummy_app_spec.rb | 3 ++- spec/unit/fixture_set_spec.rb | 16 +++++++++++----- 10 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 spec/dummy/db/migrate/20240101000003_add_deleted_at_to_projects.rb create mode 100644 spec/dummy/fixture_kit/soft_deleted_project.rb diff --git a/spec/dummy/app/models/project.rb b/spec/dummy/app/models/project.rb index 0637dd9..73e5220 100644 --- a/spec/dummy/app/models/project.rb +++ b/spec/dummy/app/models/project.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Project < ApplicationRecord + default_scope { where(deleted_at: nil) } + belongs_to :owner, class_name: "User" has_many :tasks has_many :comments, as: :commentable diff --git a/spec/dummy/db/migrate/20240101000003_add_deleted_at_to_projects.rb b/spec/dummy/db/migrate/20240101000003_add_deleted_at_to_projects.rb new file mode 100644 index 0000000..55a58b7 --- /dev/null +++ b/spec/dummy/db/migrate/20240101000003_add_deleted_at_to_projects.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddDeletedAtToProjects < ActiveRecord::Migration[8.0] + def change + add_column :projects, :deleted_at, :datetime + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 816a0d8..bc9365e 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/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: 2024_01_01_000002) do +ActiveRecord::Schema[8.1].define(version: 2024_01_01_000003) do create_table "activity_logs", force: :cascade do |t| t.string "action", null: false t.datetime "created_at", null: false @@ -34,6 +34,7 @@ create_table "projects", force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "deleted_at" t.text "description" t.string "name", null: false t.integer "owner_id", null: false diff --git a/spec/dummy/fixture_kit/soft_deleted_project.rb b/spec/dummy/fixture_kit/soft_deleted_project.rb new file mode 100644 index 0000000..16c336d --- /dev/null +++ b/spec/dummy/fixture_kit/soft_deleted_project.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FixtureKit.define do + owner = User.create!(name: "Project Owner", email: "owner@soft-delete.test") + + active_project = Project.create!(name: "Active Project", owner: owner) + + archived_project = Project.create!(name: "Archived Project", owner: owner) + archived_project.update_columns(deleted_at: Time.current) + + expose( + owner: owner, + active_project: active_project, + archived_project: archived_project + ) +end diff --git a/spec/dummy/spec/integration/fixture_kit_integration.rb b/spec/dummy/spec/integration/fixture_kit_integration.rb index b6b3952..12f3bbf 100644 --- a/spec/dummy/spec/integration/fixture_kit_integration.rb +++ b/spec/dummy/spec/integration/fixture_kit_integration.rb @@ -249,6 +249,20 @@ module FixtureKitIntegrationTimeHelpers end end + describe "unscoped record lookup" do + fixture "soft_deleted_project" + + it "finds exposed records even when hidden by default_scope" do + expect(Project.count).to eq(1) + expect(Project.unscoped.count).to eq(2) + + expect(fixture.archived_project.name).to eq("Archived Project") + expect(fixture.archived_project.deleted_at).to be_present + expect(fixture.active_project.name).to eq("Active Project") + puts "FKIT_ASSERT:UNSCOPED_RECORD_LOOKUP" + end + end + describe "fixture instance reader without declaration" do it "raises a helpful error message" do expect do diff --git a/spec/dummy/spec/rails_helper.rb b/spec/dummy/spec/rails_helper.rb index 1f31dcf..bcc00f3 100644 --- a/spec/dummy/spec/rails_helper.rb +++ b/spec/dummy/spec/rails_helper.rb @@ -42,6 +42,7 @@ def setup_databases t.string :name, null: false t.text :description t.string :status, default: "active" + t.datetime :deleted_at t.references :owner, null: false, foreign_key: { to_table: :users } t.timestamps end diff --git a/spec/dummy/test/integration/fixture_kit_integration_test.rb b/spec/dummy/test/integration/fixture_kit_integration_test.rb index b0a7a8d..7069944 100644 --- a/spec/dummy/test/integration/fixture_kit_integration_test.rb +++ b/spec/dummy/test/integration/fixture_kit_integration_test.rb @@ -200,6 +200,20 @@ class FixtureKitAnonymousDuplicateDeclarationIntegrationTest < ActiveSupport::Te end end +class FixtureKitUnscopedRecordLookupIntegrationTest < ActiveSupport::TestCase + fixture "soft_deleted_project" + + test "finds exposed records even when hidden by default_scope" do + assert_equal 1, Project.count + assert_equal 2, Project.unscoped.count + + assert_equal "Archived Project", fixture.archived_project.name + assert_not_nil fixture.archived_project.deleted_at + assert_equal "Active Project", fixture.active_project.name + puts "FKIT_ASSERT:UNSCOPED_RECORD_LOOKUP" + end +end + class FixtureKitUndeclaredFixtureReaderIntegrationTest < ActiveSupport::TestCase test "raises a helpful error message when fixture is called without declaration" do error = assert_raises(RuntimeError) { fixture } diff --git a/spec/dummy/test/test_helper.rb b/spec/dummy/test/test_helper.rb index 7779a3c..1735723 100644 --- a/spec/dummy/test/test_helper.rb +++ b/spec/dummy/test/test_helper.rb @@ -45,6 +45,7 @@ def setup_databases t.string :name, null: false t.text :description t.string :status, default: "active" + t.datetime :deleted_at t.references :owner, null: false, foreign_key: { to_table: :users } t.timestamps end diff --git a/spec/integration/dummy_app_spec.rb b/spec/integration/dummy_app_spec.rb index 610aa06..0acb412 100644 --- a/spec/integration/dummy_app_spec.rb +++ b/spec/integration/dummy_app_spec.rb @@ -73,7 +73,8 @@ def run_dummy_tests "FKIT_ASSERT:CIRCULAR_INHERITANCE", "FKIT_ASSERT:ANONYMOUS_NESTED_OVERRIDE", "FKIT_ASSERT:ANONYMOUS_DUPLICATE_DECLARATION", - "FKIT_ASSERT:UNDECLARED_FIXTURE_READER" + "FKIT_ASSERT:UNDECLARED_FIXTURE_READER", + "FKIT_ASSERT:UNSCOPED_RECORD_LOOKUP" ] if INTEGRATION_FRAMEWORK == "rspec" diff --git a/spec/unit/fixture_set_spec.rb b/spec/unit/fixture_set_spec.rb index 43b67ec..61d6446 100644 --- a/spec/unit/fixture_set_spec.rb +++ b/spec/unit/fixture_set_spec.rb @@ -12,7 +12,9 @@ } ) - expect(User).to receive(:find_by).with(id: user.id).once.and_call_original + unscoped_relation = User.unscoped + expect(User).to receive(:unscoped).once.and_return(unscoped_relation) + expect(unscoped_relation).to receive(:find_by).with(id: user.id).once.and_call_original first = repository.admin second = repository.admin @@ -34,8 +36,10 @@ } ) - expect(User).to receive(:find_by).with(id: user_one.id).once.and_call_original - expect(User).to receive(:find_by).with(id: user_two.id).once.and_call_original + unscoped_relation = User.unscoped + allow(User).to receive(:unscoped).and_return(unscoped_relation) + expect(unscoped_relation).to receive(:find_by).with(id: user_one.id).once.and_call_original + expect(unscoped_relation).to receive(:find_by).with(id: user_two.id).once.and_call_original first = repository.users second = repository.users @@ -55,8 +59,10 @@ } ) - expect(User).to receive(:find_by).with(id: user.id).once.and_call_original - expect(User).not_to receive(:find_by).with(id: other_user.id) + unscoped_relation = User.unscoped + allow(User).to receive(:unscoped).and_return(unscoped_relation) + expect(unscoped_relation).to receive(:find_by).with(id: user.id).once.and_call_original + expect(unscoped_relation).not_to receive(:find_by).with(id: other_user.id) expect(repository.user.id).to eq(user.id) end From 283a5a8d68d271702af6ef703c36e0ec8fa50268 Mon Sep 17 00:00:00 2001 From: Ngan Pham Date: Wed, 4 Mar 2026 20:46:15 -1000 Subject: [PATCH 3/4] Remove redundant Repository unit tests These tests were in a misnamed file (fixture_set_spec.rb for FixtureKit::Repository) and tested implementation details (memoization, laziness) with brittle message expectations tightly coupled to the implementation. The meaningful behaviors are already covered by integration tests (EXPOSED_ACCESS, ARRAY_EXPOSURE, UNSCOPED_RECORD_LOOKUP). Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/unit/fixture_set_spec.rb | 106 ---------------------------------- 1 file changed, 106 deletions(-) delete mode 100644 spec/unit/fixture_set_spec.rb diff --git a/spec/unit/fixture_set_spec.rb b/spec/unit/fixture_set_spec.rb deleted file mode 100644 index 61d6446..0000000 --- a/spec/unit/fixture_set_spec.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe FixtureKit::Repository do - describe "lazy exposed record loading" do - it "loads a single exposed record on first access and memoizes it" do - user = User.create!(name: "Memoized User", email: "memoized@example.com") - repository = described_class.new( - { - admin: { User => user.id } - } - ) - - unscoped_relation = User.unscoped - expect(User).to receive(:unscoped).once.and_return(unscoped_relation) - expect(unscoped_relation).to receive(:find_by).with(id: user.id).once.and_call_original - - first = repository.admin - second = repository.admin - - expect(first).to be_a(User) - expect(first.id).to eq(user.id) - expect(second).to equal(first) - end - - it "loads array exposures lazily, memoizes, and freezes the loaded array" do - user_one = User.create!(name: "Array User 1", email: "array-user-1@example.com") - user_two = User.create!(name: "Array User 2", email: "array-user-2@example.com") - repository = described_class.new( - { - users: [ - { User => user_one.id }, - { User => user_two.id } - ] - } - ) - - unscoped_relation = User.unscoped - allow(User).to receive(:unscoped).and_return(unscoped_relation) - expect(unscoped_relation).to receive(:find_by).with(id: user_one.id).once.and_call_original - expect(unscoped_relation).to receive(:find_by).with(id: user_two.id).once.and_call_original - - first = repository.users - second = repository.users - - expect(first.map(&:id)).to eq([user_one.id, user_two.id]) - expect(second).to equal(first) - expect(first).to be_frozen - end - - it "does not load other exposures when one accessor is called" do - user = User.create!(name: "Primary User", email: "primary-user@example.com") - other_user = User.create!(name: "Other User", email: "other-user@example.com") - repository = described_class.new( - { - user: { User => user.id }, - other_user: { User => other_user.id } - } - ) - - unscoped_relation = User.unscoped - allow(User).to receive(:unscoped).and_return(unscoped_relation) - expect(unscoped_relation).to receive(:find_by).with(id: user.id).once.and_call_original - expect(unscoped_relation).not_to receive(:find_by).with(id: other_user.id) - - expect(repository.user.id).to eq(user.id) - end - - it "returns nil when exposed record cannot be loaded" do - repository = described_class.new( - { - missing_user: { User => 999_999 } - } - ) - - expect(repository.missing_user).to be_nil - end - - it "loads the latest persisted state when a record changes before first access" do - user = User.create!(name: "Original User", email: "updated-before-access@example.com") - repository = described_class.new( - { - user: { User => user.id } - } - ) - - user.update!(name: "Updated Before Access") - - expect(repository.user.name).to eq("Updated Before Access") - end - - it "returns nil when a record is deleted before first access" do - user = User.create!(name: "Soon Missing", email: "soon-missing@example.com") - repository = described_class.new( - { - user: { User => user.id } - } - ) - - user.destroy! - - expect(repository.user).to be_nil - end - end -end From 615a7055c9828c279127dd20796cbab242e1d221 Mon Sep 17 00:00:00 2001 From: Ngan Pham Date: Wed, 4 Mar 2026 20:50:38 -1000 Subject: [PATCH 4/4] Remove unnecessary migration file The dummy app sets up tables inline via rails_helper/test_helper, not through migrations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrate/20240101000003_add_deleted_at_to_projects.rb | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 spec/dummy/db/migrate/20240101000003_add_deleted_at_to_projects.rb diff --git a/spec/dummy/db/migrate/20240101000003_add_deleted_at_to_projects.rb b/spec/dummy/db/migrate/20240101000003_add_deleted_at_to_projects.rb deleted file mode 100644 index 55a58b7..0000000 --- a/spec/dummy/db/migrate/20240101000003_add_deleted_at_to_projects.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddDeletedAtToProjects < ActiveRecord::Migration[8.0] - def change - add_column :projects, :deleted_at, :datetime - end -end