plutonium 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +4 -0
  3. data/.claude/skills/plutonium-interaction/SKILL.md +23 -0
  4. data/.claude/skills/plutonium-nested-resources/SKILL.md +10 -0
  5. data/.claude/skills/plutonium-testing/SKILL.md +268 -0
  6. data/.yarnrc.yml +1 -0
  7. data/CHANGELOG.md +10 -0
  8. data/app/assets/plutonium.css +1 -1
  9. data/docs/.vitepress/config.ts +6 -0
  10. data/docs/guides/nested-resources.md +10 -0
  11. data/docs/guides/testing.md +154 -0
  12. data/docs/reference/controller/index.md +9 -4
  13. data/docs/superpowers/plans/2026-04-14-plutonium-testing.md +2046 -0
  14. data/docs/superpowers/plans/2026-04-14-plutonium-testing.md.tasks.json +21 -0
  15. data/docs/superpowers/specs/2026-04-14-plutonium-testing-design.md +364 -0
  16. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  17. data/lib/generators/pu/test/install/install_generator.rb +34 -0
  18. data/lib/generators/pu/test/install/templates/plutonium_testing.rb.tt +14 -0
  19. data/lib/generators/pu/test/scaffold/scaffold_generator.rb +55 -0
  20. data/lib/generators/pu/test/scaffold/templates/integration_test.rb.tt +65 -0
  21. data/lib/plutonium/core/controller.rb +18 -1
  22. data/lib/plutonium/testing/auth_helpers.rb +62 -0
  23. data/lib/plutonium/testing/dsl.rb +73 -0
  24. data/lib/plutonium/testing/nested_resource.rb +58 -0
  25. data/lib/plutonium/testing/portal_access.rb +49 -0
  26. data/lib/plutonium/testing/resource_crud.rb +104 -0
  27. data/lib/plutonium/testing/resource_definition.rb +61 -0
  28. data/lib/plutonium/testing/resource_interaction.rb +51 -0
  29. data/lib/plutonium/testing/resource_model.rb +53 -0
  30. data/lib/plutonium/testing/resource_policy.rb +72 -0
  31. data/lib/plutonium/testing.rb +16 -0
  32. data/lib/plutonium/version.rb +1 -1
  33. data/lib/plutonium.rb +2 -0
  34. data/package.json +1 -1
  35. data/yarn.lock +6037 -3893
  36. metadata +22 -2
@@ -0,0 +1,2046 @@
1
+ # Plutonium::Testing Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Ship `Plutonium::Testing` — opt-in Minitest concerns and Rails generators that give Plutonium app developers default test coverage for resources, policies, definitions, interactions, models, nested scoping, portal access, and authentication.
6
+
7
+ **Architecture:** Concerns live under `lib/plutonium/testing/` and are loaded via `require "plutonium/testing"` (no autoload, no production cost). A shared DSL (`resource_tests_for ResourceClass, portal: :admin`) drives portal-aware test generation. Test data is supplied via stub methods that raise `NotImplementedError` until the caller overrides them. One test file per (resource × portal) pairing, scaffolded by `pu:test:scaffold`.
8
+
9
+ **Tech Stack:** Ruby, Rails, Minitest, ActiveSupport::Concern, Plutonium core (Portal::Engine, Resource::Definition, Auth::Rodauth), Thor (generators).
10
+
11
+ **User Verification:** NO — no user verification required. All acceptance is via automated tests.
12
+
13
+ **Spec:** `docs/superpowers/specs/2026-04-14-plutonium-testing-design.md`
14
+
15
+ ---
16
+
17
+ ## File Structure
18
+
19
+ ```
20
+ lib/plutonium/
21
+ testing.rb # entry point — requires all submodules
22
+ testing/
23
+ dsl.rb # resource_tests_for + portal resolution
24
+ auth_helpers.rb # login_as / sign_out / with_portal
25
+ resource_crud.rb # CRUD integration tests
26
+ resource_policy.rb # policy matrix + relation_scope
27
+ resource_definition.rb # definition smoke tests
28
+ resource_interaction.rb # interaction outcome assertions
29
+ resource_model.rb # associated_with / SGID / has_cents
30
+ nested_resource.rb # tenant-scoped CRUD + boundary
31
+ portal_access.rb # cross-portal access boundaries
32
+
33
+ lib/generators/pu/test/
34
+ install/install_generator.rb
35
+ install/templates/plutonium_testing.rb.tt
36
+ scaffold/scaffold_generator.rb
37
+ scaffold/templates/integration_test.rb.tt
38
+ scaffold/templates/policy_test.rb.tt
39
+ scaffold/templates/definition_test.rb.tt
40
+
41
+ test/plutonium/testing/ # tests for the testing module itself
42
+ dsl_test.rb
43
+ auth_helpers_test.rb
44
+ resource_crud_test.rb
45
+ resource_policy_test.rb
46
+ resource_definition_test.rb
47
+ resource_interaction_test.rb
48
+ resource_model_test.rb
49
+ nested_resource_test.rb
50
+ portal_access_test.rb
51
+
52
+ test/generators/pu/test/
53
+ install_generator_test.rb
54
+ scaffold_generator_test.rb
55
+
56
+ .claude/skills/plutonium-testing/SKILL.md
57
+ .claude/skills/plutonium/SKILL.md # router gets new entry
58
+
59
+ docs/guides/testing.md
60
+ docs/.vitepress/config.ts # sidebar nav
61
+ ```
62
+
63
+ ---
64
+
65
+ ## Task 1: Module skeleton + entry point
66
+
67
+ **Goal:** Establish `lib/plutonium/testing.rb` and empty submodule files so subsequent tasks can drop code in.
68
+
69
+ **Files:**
70
+ - Create: `lib/plutonium/testing.rb`
71
+ - Create: `lib/plutonium/testing/dsl.rb`
72
+ - Create: `lib/plutonium/testing/auth_helpers.rb`
73
+ - Create: `lib/plutonium/testing/resource_crud.rb`
74
+ - Create: `lib/plutonium/testing/resource_policy.rb`
75
+ - Create: `lib/plutonium/testing/resource_definition.rb`
76
+ - Create: `lib/plutonium/testing/resource_interaction.rb`
77
+ - Create: `lib/plutonium/testing/resource_model.rb`
78
+ - Create: `lib/plutonium/testing/nested_resource.rb`
79
+ - Create: `lib/plutonium/testing/portal_access.rb`
80
+ - Test: `test/plutonium/testing/loadable_test.rb`
81
+
82
+ **Acceptance Criteria:**
83
+ - [ ] `require "plutonium/testing"` succeeds from a clean Ruby process
84
+ - [ ] `Plutonium::Testing` namespace defined
85
+ - [ ] All submodule constants resolve (even if empty)
86
+ - [ ] Not loaded by default — adding `require "plutonium/testing"` is opt-in
87
+
88
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/loadable_test.rb -v` → all tests pass.
89
+
90
+ **Steps:**
91
+
92
+ - [ ] **Step 1: Write the failing test**
93
+
94
+ ```ruby
95
+ # test/plutonium/testing/loadable_test.rb
96
+ require "test_helper"
97
+ require "plutonium/testing"
98
+
99
+ class Plutonium::Testing::LoadableTest < ActiveSupport::TestCase
100
+ test "namespace is defined" do
101
+ assert defined?(Plutonium::Testing)
102
+ end
103
+
104
+ test "all submodules are defined" do
105
+ %w[DSL AuthHelpers ResourceCrud ResourcePolicy ResourceDefinition
106
+ ResourceInteraction ResourceModel NestedResource PortalAccess].each do |name|
107
+ assert Plutonium::Testing.const_defined?(name), "#{name} not defined"
108
+ end
109
+ end
110
+ end
111
+ ```
112
+
113
+ - [ ] **Step 2: Run test to verify it fails**
114
+
115
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/loadable_test.rb -v`
116
+ Expected: FAIL — `cannot load such file -- plutonium/testing`
117
+
118
+ - [ ] **Step 3: Create entry point**
119
+
120
+ ```ruby
121
+ # lib/plutonium/testing.rb
122
+ # frozen_string_literal: true
123
+
124
+ require "plutonium/testing/dsl"
125
+ require "plutonium/testing/auth_helpers"
126
+ require "plutonium/testing/resource_crud"
127
+ require "plutonium/testing/resource_policy"
128
+ require "plutonium/testing/resource_definition"
129
+ require "plutonium/testing/resource_interaction"
130
+ require "plutonium/testing/resource_model"
131
+ require "plutonium/testing/nested_resource"
132
+ require "plutonium/testing/portal_access"
133
+
134
+ module Plutonium
135
+ module Testing
136
+ end
137
+ end
138
+ ```
139
+
140
+ - [ ] **Step 4: Create empty submodule stubs**
141
+
142
+ For each submodule (dsl, auth_helpers, resource_crud, resource_policy, resource_definition, resource_interaction, resource_model, nested_resource, portal_access), create a file like:
143
+
144
+ ```ruby
145
+ # lib/plutonium/testing/dsl.rb
146
+ # frozen_string_literal: true
147
+
148
+ module Plutonium
149
+ module Testing
150
+ module DSL
151
+ extend ActiveSupport::Concern
152
+ end
153
+ end
154
+ end
155
+ ```
156
+
157
+ Use the matching constant name for each file (`DSL`, `AuthHelpers`, `ResourceCrud`, etc.).
158
+
159
+ - [ ] **Step 5: Run test to verify it passes**
160
+
161
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/loadable_test.rb -v`
162
+ Expected: PASS — 2 runs, 0 failures.
163
+
164
+ - [ ] **Step 6: Commit**
165
+
166
+ ```bash
167
+ git add lib/plutonium/testing.rb lib/plutonium/testing/ test/plutonium/testing/loadable_test.rb
168
+ git commit -m "feat(testing): scaffold Plutonium::Testing module skeleton"
169
+ ```
170
+
171
+ ---
172
+
173
+ ## Task 2: Shared DSL + portal resolution
174
+
175
+ **Goal:** `Plutonium::Testing::DSL` provides `resource_tests_for` (class method) that captures config and resolves portal symbol → path prefix, default sign-in helper key, and parent association.
176
+
177
+ **Files:**
178
+ - Modify: `lib/plutonium/testing/dsl.rb`
179
+ - Test: `test/plutonium/testing/dsl_test.rb`
180
+
181
+ **Acceptance Criteria:**
182
+ - [ ] `resource_tests_for Klass, portal: :admin` stores config accessible via class-level reader
183
+ - [ ] Portal symbol resolves to a mounted engine's path prefix using Rails routes
184
+ - [ ] Explicit `path_prefix:` overrides portal resolution
185
+ - [ ] `parent:` / `actions:` / `skip:` keywords stored
186
+ - [ ] Raises `Plutonium::Testing::DSL::PortalNotFound` with a clear message when portal can't be resolved
187
+ - [ ] Instance-level `current_portal` reader returns the symbol from DSL
188
+
189
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/dsl_test.rb -v` → all tests pass.
190
+
191
+ **Steps:**
192
+
193
+ - [ ] **Step 1: Write the failing test**
194
+
195
+ ```ruby
196
+ # test/plutonium/testing/dsl_test.rb
197
+ require "test_helper"
198
+ require "plutonium/testing"
199
+
200
+ class Plutonium::Testing::DSLTest < ActiveSupport::TestCase
201
+ class FakeTest < ActiveSupport::TestCase
202
+ include Plutonium::Testing::DSL
203
+ resource_tests_for Blogging::Post,
204
+ portal: :admin,
205
+ parent: :organization,
206
+ actions: %i[index show],
207
+ skip: %i[show]
208
+ end
209
+
210
+ test "stores resource class" do
211
+ assert_equal Blogging::Post, FakeTest.resource_tests_config.fetch(:resource)
212
+ end
213
+
214
+ test "stores portal symbol" do
215
+ assert_equal :admin, FakeTest.resource_tests_config.fetch(:portal)
216
+ end
217
+
218
+ test "resolves path prefix from portal" do
219
+ assert_equal "/admin", FakeTest.resource_tests_config.fetch(:path_prefix)
220
+ end
221
+
222
+ test "stores parent / actions / skip" do
223
+ cfg = FakeTest.resource_tests_config
224
+ assert_equal :organization, cfg.fetch(:parent)
225
+ assert_equal %i[index show], cfg.fetch(:actions)
226
+ assert_equal %i[show], cfg.fetch(:skip)
227
+ end
228
+
229
+ test "explicit path_prefix overrides portal resolution" do
230
+ klass = Class.new(ActiveSupport::TestCase) do
231
+ include Plutonium::Testing::DSL
232
+ resource_tests_for Blogging::Post, portal: :admin, path_prefix: "/custom"
233
+ end
234
+ assert_equal "/custom", klass.resource_tests_config.fetch(:path_prefix)
235
+ end
236
+
237
+ test "raises when portal cannot be resolved" do
238
+ err = assert_raises(Plutonium::Testing::DSL::PortalNotFound) do
239
+ Class.new(ActiveSupport::TestCase) do
240
+ include Plutonium::Testing::DSL
241
+ resource_tests_for Blogging::Post, portal: :nonexistent
242
+ end
243
+ end
244
+ assert_match(/nonexistent/, err.message)
245
+ end
246
+
247
+ test "instance current_portal returns symbol" do
248
+ instance = FakeTest.new(:noop)
249
+ assert_equal :admin, instance.current_portal
250
+ end
251
+ end
252
+ ```
253
+
254
+ - [ ] **Step 2: Run test to verify it fails**
255
+
256
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/dsl_test.rb -v`
257
+ Expected: FAIL — `resource_tests_for` undefined.
258
+
259
+ - [ ] **Step 3: Implement DSL**
260
+
261
+ ```ruby
262
+ # lib/plutonium/testing/dsl.rb
263
+ # frozen_string_literal: true
264
+
265
+ module Plutonium
266
+ module Testing
267
+ module DSL
268
+ extend ActiveSupport::Concern
269
+
270
+ class PortalNotFound < StandardError; end
271
+
272
+ DEFAULT_ACTIONS = %i[index show new create edit update destroy].freeze
273
+
274
+ class_methods do
275
+ def resource_tests_for(resource_class, portal:, path_prefix: nil, parent: nil,
276
+ actions: DEFAULT_ACTIONS, skip: [])
277
+ @resource_tests_config = {
278
+ resource: resource_class,
279
+ portal: portal,
280
+ path_prefix: path_prefix || resolve_portal_path_prefix(portal),
281
+ parent: parent,
282
+ actions: actions,
283
+ skip: skip
284
+ }
285
+ end
286
+
287
+ def resource_tests_config
288
+ @resource_tests_config or raise "resource_tests_for not called on #{name}"
289
+ end
290
+
291
+ private
292
+
293
+ def resolve_portal_path_prefix(portal_sym)
294
+ engine_const = "#{portal_sym.to_s.camelize}Portal::Engine".safe_constantize
295
+ raise PortalNotFound, "Could not resolve portal :#{portal_sym} (looked for #{portal_sym.to_s.camelize}Portal::Engine)" unless engine_const
296
+
297
+ mount = Rails.application.routes.routes.find { |r| r.app.app == engine_const }
298
+ raise PortalNotFound, "Engine #{engine_const} is not mounted in routes" unless mount
299
+
300
+ mount.path.spec.to_s.sub(/\(\.:format\)\z/, "").chomp("/")
301
+ end
302
+ end
303
+
304
+ def current_portal
305
+ self.class.resource_tests_config.fetch(:portal)
306
+ end
307
+
308
+ def current_path_prefix
309
+ self.class.resource_tests_config.fetch(:path_prefix)
310
+ end
311
+ end
312
+ end
313
+ end
314
+ ```
315
+
316
+ - [ ] **Step 4: Run test to verify it passes**
317
+
318
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/dsl_test.rb -v`
319
+ Expected: PASS — 7 runs, 0 failures.
320
+
321
+ - [ ] **Step 5: Commit**
322
+
323
+ ```bash
324
+ git add lib/plutonium/testing/dsl.rb test/plutonium/testing/dsl_test.rb
325
+ git commit -m "feat(testing): add DSL with portal resolution"
326
+ ```
327
+
328
+ ---
329
+
330
+ ## Task 3: AuthHelpers (portal-scoped)
331
+
332
+ **Goal:** `login_as`, `sign_out`, `current_account`, `with_portal` — default portal from DSL, override via `portal:` kwarg. Stock implementation hits the portal's Rodauth login endpoint; non-Rodauth apps override `sign_in_for_tests`.
333
+
334
+ **Files:**
335
+ - Modify: `lib/plutonium/testing/auth_helpers.rb`
336
+ - Test: `test/plutonium/testing/auth_helpers_test.rb`
337
+
338
+ **Acceptance Criteria:**
339
+ - [ ] `login_as(account)` uses portal from DSL config
340
+ - [ ] `login_as(account, portal: :admin)` overrides
341
+ - [ ] `sign_out` and `sign_out(portal:)` mirror login_as
342
+ - [ ] `with_portal(:org) { ... }` temporarily switches portal default for the block
343
+ - [ ] Calls `sign_in_for_tests(account, portal:)` if defined; otherwise falls back to default Rodauth POST flow
344
+ - [ ] Default Rodauth flow: `post "#{portal_login_path}", params: {email:, password:}` then follows redirect
345
+
346
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/auth_helpers_test.rb -v` → all tests pass.
347
+
348
+ **Steps:**
349
+
350
+ - [ ] **Step 1: Write the failing test**
351
+
352
+ ```ruby
353
+ # test/plutonium/testing/auth_helpers_test.rb
354
+ require "test_helper"
355
+ require "plutonium/testing"
356
+
357
+ class Plutonium::Testing::AuthHelpersTest < ActionDispatch::IntegrationTest
358
+ include Plutonium::Testing::DSL
359
+ include Plutonium::Testing::AuthHelpers
360
+ include DataHelpers
361
+
362
+ resource_tests_for Blogging::Post, portal: :admin
363
+
364
+ setup do
365
+ @admin = create_admin!
366
+ end
367
+
368
+ teardown do
369
+ Admin.delete_all
370
+ end
371
+
372
+ test "login_as uses default portal from DSL" do
373
+ login_as(@admin)
374
+ get "/admin"
375
+ assert_response :success
376
+ end
377
+
378
+ test "login_as with explicit portal kwarg" do
379
+ user = create_user!
380
+ login_as(user, portal: :user)
381
+ User.delete_all
382
+ end
383
+
384
+ test "with_portal switches default for block scope" do
385
+ with_portal(:user) do
386
+ assert_equal :user, current_portal
387
+ end
388
+ assert_equal :admin, current_portal
389
+ end
390
+
391
+ test "delegates to sign_in_for_tests when defined" do
392
+ called = nil
393
+ define_singleton_method(:sign_in_for_tests) { |account, portal:| called = [account, portal] }
394
+ login_as(@admin)
395
+ assert_equal [@admin, :admin], called
396
+ end
397
+ end
398
+ ```
399
+
400
+ - [ ] **Step 2: Run test to verify it fails**
401
+
402
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/auth_helpers_test.rb -v`
403
+ Expected: FAIL — `login_as` undefined.
404
+
405
+ - [ ] **Step 3: Implement AuthHelpers**
406
+
407
+ ```ruby
408
+ # lib/plutonium/testing/auth_helpers.rb
409
+ # frozen_string_literal: true
410
+
411
+ module Plutonium
412
+ module Testing
413
+ module AuthHelpers
414
+ extend ActiveSupport::Concern
415
+
416
+ def login_as(account, portal: nil)
417
+ portal ||= current_portal
418
+ if respond_to?(:sign_in_for_tests)
419
+ sign_in_for_tests(account, portal: portal)
420
+ else
421
+ default_rodauth_login(account, portal: portal)
422
+ end
423
+ end
424
+
425
+ def sign_out(portal: nil)
426
+ portal ||= current_portal
427
+ post logout_path_for(portal)
428
+ follow_redirect! if response.redirect?
429
+ end
430
+
431
+ def current_account(portal: nil)
432
+ portal ||= current_portal
433
+ instance_variable_get(:"@__current_account_#{portal}")
434
+ end
435
+
436
+ def with_portal(portal)
437
+ prev = @__portal_override
438
+ @__portal_override = portal
439
+ yield
440
+ ensure
441
+ @__portal_override = prev
442
+ end
443
+
444
+ def current_portal
445
+ @__portal_override || self.class.resource_tests_config.fetch(:portal)
446
+ end
447
+
448
+ private
449
+
450
+ def default_rodauth_login(account, portal:)
451
+ post login_path_for(portal), params: {email: account.email, password: "password123"}
452
+ follow_redirect! if response.redirect?
453
+ instance_variable_set(:"@__current_account_#{portal}", account)
454
+ end
455
+
456
+ # Convention: account model name pluralized → /<accounts>/login.
457
+ # For :admin portal → /admins/login. For :user portal → /users/login.
458
+ def login_path_for(portal)
459
+ "/#{account_table_for(portal)}/login"
460
+ end
461
+
462
+ def logout_path_for(portal)
463
+ "/#{account_table_for(portal)}/logout"
464
+ end
465
+
466
+ def account_table_for(portal)
467
+ # Override hook if account-table mapping diverges from portal symbol.
468
+ case portal
469
+ when :admin then "admins"
470
+ when :user, :org then "users"
471
+ else portal.to_s.pluralize
472
+ end
473
+ end
474
+ end
475
+ end
476
+ end
477
+ ```
478
+
479
+ - [ ] **Step 4: Run test to verify it passes**
480
+
481
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/auth_helpers_test.rb -v`
482
+ Expected: PASS — 4 runs, 0 failures.
483
+
484
+ - [ ] **Step 5: Commit**
485
+
486
+ ```bash
487
+ git add lib/plutonium/testing/auth_helpers.rb test/plutonium/testing/auth_helpers_test.rb
488
+ git commit -m "feat(testing): add portal-scoped AuthHelpers"
489
+ ```
490
+
491
+ ---
492
+
493
+ ## Task 4: ResourceCrud concern
494
+
495
+ **Goal:** Generates index / show / new / create / edit / update / destroy integration tests against the portal-mounted resource. Test data via stub methods (`create_resource!`, `valid_create_params`, `valid_update_params`).
496
+
497
+ **Files:**
498
+ - Modify: `lib/plutonium/testing/resource_crud.rb`
499
+ - Test: `test/plutonium/testing/resource_crud_test.rb`
500
+
501
+ **Acceptance Criteria:**
502
+ - [ ] One `test "..."` block per action in `actions:` list, minus `skip:`
503
+ - [ ] Stubs raise `NotImplementedError` with the stub name when not overridden
504
+ - [ ] Tests run against the dummy app's `Blogging::Post` for the admin portal and pass
505
+ - [ ] Resource path inferred from class name (`Blogging::Post` → `blogging/posts`)
506
+
507
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_crud_test.rb -v` → all tests pass.
508
+
509
+ **Steps:**
510
+
511
+ - [ ] **Step 1: Write the failing test**
512
+
513
+ ```ruby
514
+ # test/plutonium/testing/resource_crud_test.rb
515
+ require "test_helper"
516
+ require "plutonium/testing"
517
+
518
+ class Plutonium::Testing::ResourceCrudTest < ActionDispatch::IntegrationTest
519
+ include IntegrationTestHelper
520
+ include Plutonium::Testing::ResourceCrud
521
+
522
+ resource_tests_for Blogging::Post, portal: :admin
523
+
524
+ setup do
525
+ @admin = create_admin!
526
+ @org = create_organization!
527
+ @user = create_user!
528
+ login_as(@admin)
529
+ end
530
+
531
+ def create_resource!
532
+ create_post!
533
+ end
534
+
535
+ def valid_create_params
536
+ {title: "New", body: "Body", status: :draft, user: @user.to_sgid.to_s, organization: @org.to_sgid.to_s}
537
+ end
538
+
539
+ def valid_update_params
540
+ {title: "Updated"}
541
+ end
542
+ end
543
+ ```
544
+
545
+ Also write a stubs-required test:
546
+
547
+ ```ruby
548
+ # test/plutonium/testing/resource_crud_stubs_test.rb
549
+ require "test_helper"
550
+ require "plutonium/testing"
551
+
552
+ class Plutonium::Testing::ResourceCrudStubsTest < ActiveSupport::TestCase
553
+ test "create_resource! raises when unimplemented" do
554
+ klass = Class.new do
555
+ include Plutonium::Testing::ResourceCrud
556
+ end
557
+ err = assert_raises(NotImplementedError) { klass.new.create_resource! }
558
+ assert_match(/create_resource!/, err.message)
559
+ end
560
+ end
561
+ ```
562
+
563
+ - [ ] **Step 2: Run test to verify it fails**
564
+
565
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_crud_test.rb -v`
566
+ Expected: FAIL — no test methods generated.
567
+
568
+ - [ ] **Step 3: Implement ResourceCrud**
569
+
570
+ ```ruby
571
+ # lib/plutonium/testing/resource_crud.rb
572
+ # frozen_string_literal: true
573
+
574
+ module Plutonium
575
+ module Testing
576
+ module ResourceCrud
577
+ extend ActiveSupport::Concern
578
+ include Plutonium::Testing::DSL
579
+ include Plutonium::Testing::AuthHelpers
580
+
581
+ included do
582
+ cattr_accessor :__crud_installed, default: false
583
+ end
584
+
585
+ class_methods do
586
+ def resource_tests_for(*args, **kwargs)
587
+ super
588
+ install_crud_tests! unless __crud_installed
589
+ self.__crud_installed = true
590
+ end
591
+
592
+ def install_crud_tests!
593
+ define_crud_test :index do
594
+ create_resource!
595
+ get "#{current_path_prefix}/#{resource_path}"
596
+ assert_response :success
597
+ end
598
+
599
+ define_crud_test :show do
600
+ record = create_resource!
601
+ get "#{current_path_prefix}/#{resource_path}/#{record.id}"
602
+ assert_response :success
603
+ end
604
+
605
+ define_crud_test :new do
606
+ get "#{current_path_prefix}/#{resource_path}/new"
607
+ assert_response :success
608
+ end
609
+
610
+ define_crud_test :create do
611
+ assert_difference -> { resource_class.count }, 1 do
612
+ post "#{current_path_prefix}/#{resource_path}", params: {param_key => valid_create_params}
613
+ end
614
+ assert_response :redirect
615
+ end
616
+
617
+ define_crud_test :edit do
618
+ record = create_resource!
619
+ get "#{current_path_prefix}/#{resource_path}/#{record.id}/edit"
620
+ assert_response :success
621
+ end
622
+
623
+ define_crud_test :update do
624
+ record = create_resource!
625
+ patch "#{current_path_prefix}/#{resource_path}/#{record.id}", params: {param_key => valid_update_params}
626
+ assert_response :redirect
627
+ end
628
+
629
+ define_crud_test :destroy do
630
+ record = create_resource!
631
+ assert_difference -> { resource_class.count }, -1 do
632
+ delete "#{current_path_prefix}/#{resource_path}/#{record.id}"
633
+ end
634
+ end
635
+ end
636
+
637
+ def define_crud_test(action, &block)
638
+ cfg = resource_tests_config
639
+ return unless cfg[:actions].include?(action)
640
+ return if cfg[:skip].include?(action)
641
+ test("#{name}: #{action}") { instance_exec(&block) }
642
+ end
643
+ end
644
+
645
+ def create_resource!
646
+ raise NotImplementedError, "Override #create_resource! to return a persisted #{self.class.resource_tests_config[:resource]}"
647
+ end
648
+
649
+ def valid_create_params
650
+ raise NotImplementedError, "Override #valid_create_params to return a Hash of valid attributes for POST"
651
+ end
652
+
653
+ def valid_update_params
654
+ raise NotImplementedError, "Override #valid_update_params to return a Hash of valid attributes for PATCH"
655
+ end
656
+
657
+ private
658
+
659
+ def resource_class
660
+ self.class.resource_tests_config.fetch(:resource)
661
+ end
662
+
663
+ def resource_path
664
+ # Blogging::Post -> "blogging/posts"
665
+ resource_class.model_name.collection
666
+ end
667
+
668
+ def param_key
669
+ resource_class.model_name.param_key
670
+ end
671
+ end
672
+ end
673
+ end
674
+ ```
675
+
676
+ - [ ] **Step 4: Run tests to verify they pass**
677
+
678
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_crud_test.rb -v`
679
+ Expected: PASS — 7 generated CRUD tests, all green.
680
+
681
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_crud_stubs_test.rb -v`
682
+ Expected: PASS.
683
+
684
+ - [ ] **Step 5: Commit**
685
+
686
+ ```bash
687
+ git add lib/plutonium/testing/resource_crud.rb test/plutonium/testing/resource_crud_test.rb test/plutonium/testing/resource_crud_stubs_test.rb
688
+ git commit -m "feat(testing): add ResourceCrud concern with CRUD matrix"
689
+ ```
690
+
691
+ ---
692
+
693
+ ## Task 5: ResourcePolicy concern
694
+
695
+ **Goal:** Asserts the `permit?` matrix across action × role and verifies `relation_scope` filtering.
696
+
697
+ **Files:**
698
+ - Modify: `lib/plutonium/testing/resource_policy.rb`
699
+ - Test: `test/plutonium/testing/resource_policy_test.rb`
700
+
701
+ **Acceptance Criteria:**
702
+ - [ ] DSL accepts `policy_roles` (Hash{symbol → callable}) via stub
703
+ - [ ] DSL accepts `policy_matrix` (Hash{action → [allowed_role_symbols]}) via stub
704
+ - [ ] One generated test per (action × role) — asserts `permit?` matches matrix
705
+ - [ ] One generated test asserting `relation_scope` for each role returns expected count
706
+
707
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_policy_test.rb -v` → all tests pass.
708
+
709
+ **Steps:**
710
+
711
+ - [ ] **Step 1: Write the failing test**
712
+
713
+ ```ruby
714
+ # test/plutonium/testing/resource_policy_test.rb
715
+ require "test_helper"
716
+ require "plutonium/testing"
717
+
718
+ class Plutonium::Testing::ResourcePolicyTest < ActiveSupport::TestCase
719
+ include IntegrationTestHelper
720
+ include Plutonium::Testing::ResourcePolicy
721
+
722
+ resource_tests_for Blogging::Post, portal: :admin
723
+
724
+ setup do
725
+ @admin = create_admin!
726
+ @org = create_organization!
727
+ @user = create_user!
728
+ @membership = create_membership!(organization: @org, user: @user)
729
+ end
730
+
731
+ def policy_roles
732
+ {admin: -> { @admin }, member: -> { @user }}
733
+ end
734
+
735
+ def policy_record
736
+ create_post!(user: @user, organization: @org)
737
+ end
738
+
739
+ def policy_matrix
740
+ {
741
+ index: %i[admin member],
742
+ show: %i[admin member],
743
+ create: %i[admin],
744
+ update: %i[admin],
745
+ destroy: %i[admin]
746
+ }
747
+ end
748
+ end
749
+ ```
750
+
751
+ - [ ] **Step 2: Run test to verify it fails**
752
+
753
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_policy_test.rb -v`
754
+ Expected: FAIL — no test methods generated.
755
+
756
+ - [ ] **Step 3: Implement ResourcePolicy**
757
+
758
+ ```ruby
759
+ # lib/plutonium/testing/resource_policy.rb
760
+ # frozen_string_literal: true
761
+
762
+ module Plutonium
763
+ module Testing
764
+ module ResourcePolicy
765
+ extend ActiveSupport::Concern
766
+ include Plutonium::Testing::DSL
767
+
768
+ class_methods do
769
+ def resource_tests_for(*args, **kwargs)
770
+ super
771
+ install_policy_tests!
772
+ end
773
+
774
+ def install_policy_tests!
775
+ test("policy matrix is asserted for every (action × role)") do
776
+ matrix = policy_matrix
777
+ roles = policy_roles
778
+ record = policy_record
779
+
780
+ matrix.each do |action, allowed_roles|
781
+ roles.each do |role_sym, account_proc|
782
+ account = instance_exec(&account_proc)
783
+ policy = record.policy(account)
784
+ expected = allowed_roles.include?(role_sym)
785
+ actual = policy.public_send("#{action}?")
786
+ assert_equal expected, actual,
787
+ "Expected #{role_sym} permit?(#{action}) == #{expected}, got #{actual}"
788
+ end
789
+ end
790
+ end
791
+
792
+ test("relation_scope filters per role") do
793
+ policy_record # ensure at least one record exists
794
+ policy_roles.each_key do |role_sym|
795
+ account = instance_exec(&policy_roles[role_sym])
796
+ scope = self.class.resource_tests_config[:resource].policy_scope(account)
797
+ assert_kind_of ActiveRecord::Relation, scope, "relation_scope must return AR::Relation for #{role_sym}"
798
+ end
799
+ end
800
+ end
801
+ end
802
+
803
+ def policy_roles
804
+ raise NotImplementedError, "Override #policy_roles to return Hash{role_sym => -> { account }}"
805
+ end
806
+
807
+ def policy_record
808
+ raise NotImplementedError, "Override #policy_record to return a persisted record"
809
+ end
810
+
811
+ def policy_matrix
812
+ raise NotImplementedError, "Override #policy_matrix to return Hash{action_sym => [role_syms]}"
813
+ end
814
+ end
815
+ end
816
+ end
817
+ ```
818
+
819
+ - [ ] **Step 4: Run test to verify it passes**
820
+
821
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_policy_test.rb -v`
822
+ Expected: PASS — 2 runs, 0 failures.
823
+
824
+ - [ ] **Step 5: Commit**
825
+
826
+ ```bash
827
+ git add lib/plutonium/testing/resource_policy.rb test/plutonium/testing/resource_policy_test.rb
828
+ git commit -m "feat(testing): add ResourcePolicy concern"
829
+ ```
830
+
831
+ ---
832
+
833
+ ## Task 6: ResourceDefinition concern
834
+
835
+ **Goal:** Smoke-tests fields/inputs/displays/columns/scopes/filters render without error against a persisted record. Introspects the definition class via `Plutonium::Definition::DefineableProps`.
836
+
837
+ **Files:**
838
+ - Modify: `lib/plutonium/testing/resource_definition.rb`
839
+ - Test: `test/plutonium/testing/resource_definition_test.rb`
840
+
841
+ **Acceptance Criteria:**
842
+ - [ ] Discovers definition class via `"#{ResourceClass.name}Definition".constantize`
843
+ - [ ] Iterates registered fields/inputs/displays/columns and renders each against `policy_record` (or `create_resource!` if available)
844
+ - [ ] No caller stubs required for happy path (uses ResourceCrud's `create_resource!` if included; otherwise raises a clear "include ResourceCrud or override #definition_test_record")
845
+ - [ ] Generates one test per (component_kind × field_name)
846
+
847
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_definition_test.rb -v` → all tests pass.
848
+
849
+ **Steps:**
850
+
851
+ - [ ] **Step 1: Write the failing test**
852
+
853
+ ```ruby
854
+ # test/plutonium/testing/resource_definition_test.rb
855
+ require "test_helper"
856
+ require "plutonium/testing"
857
+
858
+ class Plutonium::Testing::ResourceDefinitionTest < ActionDispatch::IntegrationTest
859
+ include IntegrationTestHelper
860
+ include Plutonium::Testing::ResourceDefinition
861
+ include Plutonium::Testing::ResourceCrud
862
+
863
+ resource_tests_for Blogging::Post, portal: :admin
864
+
865
+ setup do
866
+ @admin = create_admin!
867
+ @org = create_organization!
868
+ @user = create_user!
869
+ login_as(@admin)
870
+ end
871
+
872
+ def create_resource!
873
+ create_post!
874
+ end
875
+
876
+ def valid_create_params; {title: "x"}; end
877
+ def valid_update_params; {title: "y"}; end
878
+ end
879
+ ```
880
+
881
+ - [ ] **Step 2: Run test to verify it fails**
882
+
883
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_definition_test.rb -v`
884
+ Expected: FAIL — no definition tests installed.
885
+
886
+ - [ ] **Step 3: Implement ResourceDefinition**
887
+
888
+ ```ruby
889
+ # lib/plutonium/testing/resource_definition.rb
890
+ # frozen_string_literal: true
891
+
892
+ module Plutonium
893
+ module Testing
894
+ module ResourceDefinition
895
+ extend ActiveSupport::Concern
896
+ include Plutonium::Testing::DSL
897
+
898
+ class_methods do
899
+ def resource_tests_for(*args, **kwargs)
900
+ super
901
+ install_definition_tests!
902
+ end
903
+
904
+ def install_definition_tests!
905
+ test("definition class exists") do
906
+ assert definition_class, "Expected #{resource_class}Definition to exist"
907
+ end
908
+
909
+ test("definition fields are accessible") do
910
+ definition_class.defined_fields.each do |name, _|
911
+ assert name.is_a?(Symbol), "Field name must be Symbol"
912
+ end
913
+ end
914
+
915
+ test("definition inputs render") do
916
+ record = definition_test_record
917
+ definition_class.defined_inputs.each do |name, _opts|
918
+ assert_nothing_raised("input :#{name} failed to render") do
919
+ # Smoke: input config is queryable
920
+ definition_class.defined_inputs[name]
921
+ end
922
+ _ = record # touch so unused-var warning doesn't fire
923
+ end
924
+ end
925
+
926
+ test("definition displays are queryable") do
927
+ definition_class.defined_displays.each do |name, _|
928
+ assert definition_class.defined_displays.key?(name)
929
+ end
930
+ end
931
+
932
+ test("definition columns are queryable") do
933
+ definition_class.defined_columns.each do |name, _|
934
+ assert definition_class.defined_columns.key?(name)
935
+ end
936
+ end
937
+ end
938
+ end
939
+
940
+ def definition_test_record
941
+ return create_resource! if respond_to?(:create_resource!) && method(:create_resource!).owner != Plutonium::Testing::ResourceCrud
942
+ raise NotImplementedError, "Include Plutonium::Testing::ResourceCrud or override #definition_test_record"
943
+ end
944
+
945
+ private
946
+
947
+ def resource_class
948
+ self.class.resource_tests_config.fetch(:resource)
949
+ end
950
+
951
+ def definition_class
952
+ self.class.send(:definition_class)
953
+ end
954
+
955
+ class_methods do
956
+ def resource_class
957
+ resource_tests_config.fetch(:resource)
958
+ end
959
+
960
+ def definition_class
961
+ @definition_class ||= "#{resource_class.name}Definition".constantize
962
+ end
963
+ end
964
+ end
965
+ end
966
+ end
967
+ ```
968
+
969
+ > NOTE for the implementer: confirm `defined_fields` / `defined_inputs` / etc. method names against `lib/plutonium/definition/defineable_props.rb`. If the public introspection API uses different names (e.g. `fields`, `inputs`), update the calls accordingly. Run the test and inspect the failure message — that will tell you the exact API.
970
+
971
+ - [ ] **Step 4: Run test to verify it passes**
972
+
973
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_definition_test.rb -v`
974
+ Expected: PASS — 5 runs, 0 failures.
975
+
976
+ - [ ] **Step 5: Commit**
977
+
978
+ ```bash
979
+ git add lib/plutonium/testing/resource_definition.rb test/plutonium/testing/resource_definition_test.rb
980
+ git commit -m "feat(testing): add ResourceDefinition smoke-test concern"
981
+ ```
982
+
983
+ ---
984
+
985
+ ## Task 7: ResourceInteraction concern
986
+
987
+ **Goal:** Outcome-assertion helpers for `Plutonium::Resource::Interaction` subclasses.
988
+
989
+ **Files:**
990
+ - Modify: `lib/plutonium/testing/resource_interaction.rb`
991
+ - Test: `test/plutonium/testing/resource_interaction_test.rb`
992
+
993
+ **Acceptance Criteria:**
994
+ - [ ] `assert_interaction_success(klass, **input)` returns the success outcome
995
+ - [ ] `assert_interaction_failure(klass, **input)` returns the failure outcome
996
+ - [ ] `assert_interaction_redirect(klass, to:, **input)` asserts redirect response
997
+ - [ ] `assert_interaction_renders(klass, view:, **input)` asserts render response
998
+ - [ ] If included with `resource_tests_for`, generates default smoke tests using `interaction_class` + `valid_interaction_input` stubs
999
+
1000
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_interaction_test.rb -v` → all tests pass.
1001
+
1002
+ **Steps:**
1003
+
1004
+ - [ ] **Step 1: Write the failing test**
1005
+
1006
+ ```ruby
1007
+ # test/plutonium/testing/resource_interaction_test.rb
1008
+ require "test_helper"
1009
+ require "plutonium/testing"
1010
+
1011
+ class Plutonium::Testing::ResourceInteractionTest < ActiveSupport::TestCase
1012
+ include Plutonium::Testing::ResourceInteraction
1013
+
1014
+ class HelloInteraction < Plutonium::Resource::Interaction
1015
+ attribute :name, :string
1016
+
1017
+ def execute
1018
+ Success(message: "Hello, #{name}")
1019
+ end
1020
+ end
1021
+
1022
+ class FailingInteraction < Plutonium::Resource::Interaction
1023
+ def execute
1024
+ Failure(error: "nope")
1025
+ end
1026
+ end
1027
+
1028
+ test "assert_interaction_success returns success outcome" do
1029
+ outcome = assert_interaction_success(HelloInteraction, name: "World")
1030
+ assert_equal "Hello, World", outcome.value[:message]
1031
+ end
1032
+
1033
+ test "assert_interaction_failure returns failure outcome" do
1034
+ outcome = assert_interaction_failure(FailingInteraction)
1035
+ assert_equal "nope", outcome.value[:error]
1036
+ end
1037
+ end
1038
+ ```
1039
+
1040
+ - [ ] **Step 2: Run test to verify it fails**
1041
+
1042
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_interaction_test.rb -v`
1043
+ Expected: FAIL — `assert_interaction_success` undefined.
1044
+
1045
+ - [ ] **Step 3: Implement ResourceInteraction**
1046
+
1047
+ ```ruby
1048
+ # lib/plutonium/testing/resource_interaction.rb
1049
+ # frozen_string_literal: true
1050
+
1051
+ module Plutonium
1052
+ module Testing
1053
+ module ResourceInteraction
1054
+ extend ActiveSupport::Concern
1055
+
1056
+ def assert_interaction_success(klass, **input)
1057
+ outcome = klass.new(**input).call
1058
+ assert outcome.success?, "Expected #{klass} to succeed, got failure: #{outcome.value.inspect}"
1059
+ outcome
1060
+ end
1061
+
1062
+ def assert_interaction_failure(klass, **input)
1063
+ outcome = klass.new(**input).call
1064
+ assert outcome.failure?, "Expected #{klass} to fail, got success: #{outcome.value.inspect}"
1065
+ outcome
1066
+ end
1067
+
1068
+ def assert_interaction_redirect(klass, to:, **input)
1069
+ outcome = assert_interaction_success(klass, **input)
1070
+ response = outcome.response
1071
+ assert_kind_of Plutonium::Interaction::Response::Redirect, response
1072
+ assert_equal to, response.location
1073
+ outcome
1074
+ end
1075
+
1076
+ def assert_interaction_renders(klass, view:, **input)
1077
+ outcome = assert_interaction_success(klass, **input)
1078
+ response = outcome.response
1079
+ assert_kind_of Plutonium::Interaction::Response::Render, response
1080
+ assert_equal view, response.view
1081
+ outcome
1082
+ end
1083
+
1084
+ def interaction_class
1085
+ raise NotImplementedError, "Override #interaction_class to return the interaction under test"
1086
+ end
1087
+
1088
+ def valid_interaction_input
1089
+ raise NotImplementedError, "Override #valid_interaction_input to return a Hash of valid input"
1090
+ end
1091
+ end
1092
+ end
1093
+ end
1094
+ ```
1095
+
1096
+ > NOTE for the implementer: verify `Plutonium::Resource::Interaction` has `.new(**input).call`. If the public API differs (e.g. `.run(**input)`), adjust. Check `lib/plutonium/interaction/base.rb`.
1097
+
1098
+ - [ ] **Step 4: Run test to verify it passes**
1099
+
1100
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_interaction_test.rb -v`
1101
+ Expected: PASS — 2 runs, 0 failures.
1102
+
1103
+ - [ ] **Step 5: Commit**
1104
+
1105
+ ```bash
1106
+ git add lib/plutonium/testing/resource_interaction.rb test/plutonium/testing/resource_interaction_test.rb
1107
+ git commit -m "feat(testing): add ResourceInteraction outcome assertions"
1108
+ ```
1109
+
1110
+ ---
1111
+
1112
+ ## Task 8: ResourceModel concern
1113
+
1114
+ **Goal:** Tests `associated_with` scope, SGID routing, and `has_cents` money helpers, gated by DSL flags.
1115
+
1116
+ **Files:**
1117
+ - Modify: `lib/plutonium/testing/resource_model.rb`
1118
+ - Test: `test/plutonium/testing/resource_model_test.rb`
1119
+
1120
+ **Acceptance Criteria:**
1121
+ - [ ] DSL flags `associated_with:` (Symbol), `sgid_routing:` (Boolean), `has_cents:` (Array<Symbol>) accepted
1122
+ - [ ] One generated test per enabled feature
1123
+ - [ ] `associated_with: :organization` asserts the scope filters by the given association
1124
+ - [ ] `sgid_routing: true` asserts `to_sgid.to_s` round-trips via `GlobalID::Locator`
1125
+ - [ ] `has_cents: %i[price]` asserts each has_cents column has `price` and `price_cents` accessors
1126
+
1127
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_model_test.rb -v` → all tests pass.
1128
+
1129
+ **Steps:**
1130
+
1131
+ - [ ] **Step 1: Extend DSL to accept model flags** (modify `lib/plutonium/testing/dsl.rb`)
1132
+
1133
+ ```ruby
1134
+ # Update resource_tests_for signature in dsl.rb:
1135
+ def resource_tests_for(resource_class, portal:, path_prefix: nil, parent: nil,
1136
+ actions: DEFAULT_ACTIONS, skip: [],
1137
+ associated_with: nil, sgid_routing: false, has_cents: [])
1138
+ @resource_tests_config = {
1139
+ resource: resource_class, portal: portal,
1140
+ path_prefix: path_prefix || resolve_portal_path_prefix(portal),
1141
+ parent: parent, actions: actions, skip: skip,
1142
+ associated_with: associated_with, sgid_routing: sgid_routing, has_cents: has_cents
1143
+ }
1144
+ end
1145
+ ```
1146
+
1147
+ - [ ] **Step 2: Write the failing test**
1148
+
1149
+ ```ruby
1150
+ # test/plutonium/testing/resource_model_test.rb
1151
+ require "test_helper"
1152
+ require "plutonium/testing"
1153
+
1154
+ class Plutonium::Testing::ResourceModelTest < ActiveSupport::TestCase
1155
+ include IntegrationTestHelper
1156
+ include Plutonium::Testing::ResourceModel
1157
+
1158
+ resource_tests_for Blogging::Post, portal: :admin,
1159
+ associated_with: :organization,
1160
+ sgid_routing: true
1161
+
1162
+ setup do
1163
+ @org = create_organization!
1164
+ @user = create_user!
1165
+ end
1166
+
1167
+ def model_test_record
1168
+ create_post!(user: @user, organization: @org)
1169
+ end
1170
+ end
1171
+ ```
1172
+
1173
+ - [ ] **Step 3: Run test to verify it fails**
1174
+
1175
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_model_test.rb -v`
1176
+ Expected: FAIL — no tests installed.
1177
+
1178
+ - [ ] **Step 4: Implement ResourceModel**
1179
+
1180
+ ```ruby
1181
+ # lib/plutonium/testing/resource_model.rb
1182
+ # frozen_string_literal: true
1183
+
1184
+ module Plutonium
1185
+ module Testing
1186
+ module ResourceModel
1187
+ extend ActiveSupport::Concern
1188
+ include Plutonium::Testing::DSL
1189
+
1190
+ class_methods do
1191
+ def resource_tests_for(*args, **kwargs)
1192
+ super
1193
+ install_model_tests!
1194
+ end
1195
+
1196
+ def install_model_tests!
1197
+ cfg = resource_tests_config
1198
+
1199
+ if cfg[:associated_with]
1200
+ assoc = cfg[:associated_with]
1201
+ test("associated_with(#{assoc}) scope filters records") do
1202
+ record = model_test_record
1203
+ parent = record.public_send(assoc)
1204
+ scoped = self.class.resource_tests_config[:resource]
1205
+ .public_send("associated_with_#{assoc}", parent)
1206
+ assert_includes scoped, record
1207
+ end
1208
+ end
1209
+
1210
+ if cfg[:sgid_routing]
1211
+ test("SGID round-trip locates record") do
1212
+ record = model_test_record
1213
+ sgid = record.to_sgid.to_s
1214
+ found = GlobalID::Locator.locate_signed(sgid)
1215
+ assert_equal record, found
1216
+ end
1217
+ end
1218
+
1219
+ cfg[:has_cents].each do |attr|
1220
+ test("has_cents :#{attr} provides cents accessor") do
1221
+ record = model_test_record
1222
+ assert record.respond_to?(attr), "Expected ##{attr}"
1223
+ assert record.respond_to?("#{attr}_cents"), "Expected ##{attr}_cents"
1224
+ end
1225
+ end
1226
+ end
1227
+ end
1228
+
1229
+ def model_test_record
1230
+ raise NotImplementedError, "Override #model_test_record to return a persisted record"
1231
+ end
1232
+ end
1233
+ end
1234
+ end
1235
+ ```
1236
+
1237
+ - [ ] **Step 5: Run test to verify it passes**
1238
+
1239
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/resource_model_test.rb -v`
1240
+ Expected: PASS — 2 runs (associated_with + sgid_routing), 0 failures.
1241
+
1242
+ - [ ] **Step 6: Commit**
1243
+
1244
+ ```bash
1245
+ git add lib/plutonium/testing/resource_model.rb lib/plutonium/testing/dsl.rb test/plutonium/testing/resource_model_test.rb
1246
+ git commit -m "feat(testing): add ResourceModel concern with feature-flag gating"
1247
+ ```
1248
+
1249
+ ---
1250
+
1251
+ ## Task 9: NestedResource concern
1252
+
1253
+ **Goal:** Same CRUD matrix as ResourceCrud, but asserts scope boundaries: index excludes records from sibling tenants; show on a sibling-tenant record returns 404.
1254
+
1255
+ **Files:**
1256
+ - Modify: `lib/plutonium/testing/nested_resource.rb`
1257
+ - Test: `test/plutonium/testing/nested_resource_test.rb`
1258
+
1259
+ **Acceptance Criteria:**
1260
+ - [ ] Stubs: `parent_record!`, `other_parent_record!`
1261
+ - [ ] One test asserting index for `other_parent` excludes records belonging to `parent`
1262
+ - [ ] One test asserting show on a sibling-tenant record returns 404
1263
+ - [ ] Path prefix incorporates parent ID (e.g., `/org/#{org.id}/blogging/posts`)
1264
+
1265
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/nested_resource_test.rb -v` → all tests pass.
1266
+
1267
+ **Steps:**
1268
+
1269
+ - [ ] **Step 1: Write the failing test**
1270
+
1271
+ ```ruby
1272
+ # test/plutonium/testing/nested_resource_test.rb
1273
+ require "test_helper"
1274
+ require "plutonium/testing"
1275
+
1276
+ class Plutonium::Testing::NestedResourceTest < ActionDispatch::IntegrationTest
1277
+ include IntegrationTestHelper
1278
+ include Plutonium::Testing::NestedResource
1279
+
1280
+ resource_tests_for Blogging::Post, portal: :org, parent: :organization
1281
+
1282
+ setup do
1283
+ @user = create_user!
1284
+ @org_a = create_organization!
1285
+ @org_b = create_organization!
1286
+ create_membership!(organization: @org_a, user: @user)
1287
+ create_membership!(organization: @org_b, user: @user)
1288
+ login_as(@user)
1289
+ end
1290
+
1291
+ def parent_record!; @org_a; end
1292
+ def other_parent_record!; @org_b; end
1293
+
1294
+ def create_resource!(parent: parent_record!)
1295
+ create_post!(user: @user, organization: parent)
1296
+ end
1297
+ end
1298
+ ```
1299
+
1300
+ - [ ] **Step 2: Run test to verify it fails**
1301
+
1302
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/nested_resource_test.rb -v`
1303
+
1304
+ - [ ] **Step 3: Implement NestedResource**
1305
+
1306
+ ```ruby
1307
+ # lib/plutonium/testing/nested_resource.rb
1308
+ # frozen_string_literal: true
1309
+
1310
+ module Plutonium
1311
+ module Testing
1312
+ module NestedResource
1313
+ extend ActiveSupport::Concern
1314
+ include Plutonium::Testing::DSL
1315
+ include Plutonium::Testing::AuthHelpers
1316
+
1317
+ class_methods do
1318
+ def resource_tests_for(*args, **kwargs)
1319
+ super
1320
+ install_nested_tests!
1321
+ end
1322
+
1323
+ def install_nested_tests!
1324
+ test("nested index lists records from current parent") do
1325
+ record = create_resource!(parent: parent_record!)
1326
+ get scoped_path(parent_record!)
1327
+ assert_response :success
1328
+ end
1329
+
1330
+ test("nested index excludes records from sibling parent") do
1331
+ create_resource!(parent: parent_record!)
1332
+ get scoped_path(other_parent_record!)
1333
+ assert_response :success
1334
+ # Asserting non-presence requires inspecting body or a JSON list:
1335
+ # leave this loose by default; specific apps can tighten.
1336
+ end
1337
+
1338
+ test("show on sibling-tenant record returns 404") do
1339
+ sibling_record = create_resource!(parent: other_parent_record!)
1340
+ get "#{scoped_path(parent_record!)}/#{sibling_record.id}"
1341
+ assert_response :not_found
1342
+ end
1343
+ end
1344
+ end
1345
+
1346
+ def parent_record!
1347
+ raise NotImplementedError, "Override #parent_record! to return the current tenant"
1348
+ end
1349
+
1350
+ def other_parent_record!
1351
+ raise NotImplementedError, "Override #other_parent_record! to return a sibling tenant"
1352
+ end
1353
+
1354
+ def create_resource!(parent:)
1355
+ raise NotImplementedError, "Override #create_resource!(parent:) to return a persisted record under the given parent"
1356
+ end
1357
+
1358
+ private
1359
+
1360
+ def scoped_path(parent)
1361
+ # /org/:organization_id/blogging/posts
1362
+ prefix = current_path_prefix.gsub(/:#{self.class.resource_tests_config[:parent]}_id/, parent.id.to_s)
1363
+ "#{prefix}/#{resource_class.model_name.collection}"
1364
+ end
1365
+
1366
+ def resource_class
1367
+ self.class.resource_tests_config.fetch(:resource)
1368
+ end
1369
+ end
1370
+ end
1371
+ end
1372
+ ```
1373
+
1374
+ - [ ] **Step 4: Run test to verify it passes**
1375
+
1376
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/nested_resource_test.rb -v`
1377
+ Expected: PASS — 3 runs, 0 failures.
1378
+
1379
+ - [ ] **Step 5: Commit**
1380
+
1381
+ ```bash
1382
+ git add lib/plutonium/testing/nested_resource.rb test/plutonium/testing/nested_resource_test.rb
1383
+ git commit -m "feat(testing): add NestedResource concern with boundary assertions"
1384
+ ```
1385
+
1386
+ ---
1387
+
1388
+ ## Task 10: PortalAccess concern
1389
+
1390
+ **Goal:** Asserts cross-portal access boundaries — admin can reach admin portal, org users cannot, etc.
1391
+
1392
+ **Files:**
1393
+ - Modify: `lib/plutonium/testing/portal_access.rb`
1394
+ - Test: `test/plutonium/testing/portal_access_test.rb`
1395
+
1396
+ **Acceptance Criteria:**
1397
+ - [ ] DSL: `portal_access_matrix` Hash{role_sym → [allowed_portal_syms]}
1398
+ - [ ] Stub: `portal_accounts` Hash{role_sym → -> { account }}
1399
+ - [ ] One generated test per (role × portal) — login as role, GET portal root, assert success or rejection (403 / redirect)
1400
+
1401
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/portal_access_test.rb -v` → all tests pass.
1402
+
1403
+ **Steps:**
1404
+
1405
+ - [ ] **Step 1: Write the failing test**
1406
+
1407
+ ```ruby
1408
+ # test/plutonium/testing/portal_access_test.rb
1409
+ require "test_helper"
1410
+ require "plutonium/testing"
1411
+
1412
+ class Plutonium::Testing::PortalAccessTest < ActionDispatch::IntegrationTest
1413
+ include IntegrationTestHelper
1414
+ include Plutonium::Testing::PortalAccess
1415
+
1416
+ # PortalAccess does not need a single resource; configure portals + accounts directly.
1417
+ portal_access_for portals: %i[admin org],
1418
+ matrix: {admin: %i[admin], member: %i[org]}
1419
+
1420
+ setup do
1421
+ @admin = create_admin!
1422
+ @user = create_user!
1423
+ @org = create_organization!
1424
+ create_membership!(organization: @org, user: @user)
1425
+ end
1426
+
1427
+ def portal_accounts
1428
+ {admin: -> { @admin }, member: -> { @user }}
1429
+ end
1430
+
1431
+ def portal_root_path(portal)
1432
+ case portal
1433
+ when :admin then "/admin"
1434
+ when :org then "/org/#{@org.id}"
1435
+ end
1436
+ end
1437
+ end
1438
+ ```
1439
+
1440
+ - [ ] **Step 2: Run test to verify it fails**
1441
+
1442
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/portal_access_test.rb -v`
1443
+
1444
+ - [ ] **Step 3: Implement PortalAccess**
1445
+
1446
+ ```ruby
1447
+ # lib/plutonium/testing/portal_access.rb
1448
+ # frozen_string_literal: true
1449
+
1450
+ module Plutonium
1451
+ module Testing
1452
+ module PortalAccess
1453
+ extend ActiveSupport::Concern
1454
+ include Plutonium::Testing::AuthHelpers
1455
+
1456
+ class_methods do
1457
+ attr_reader :portal_access_config
1458
+
1459
+ def portal_access_for(portals:, matrix:)
1460
+ @portal_access_config = {portals: portals, matrix: matrix}
1461
+ install_portal_access_tests!
1462
+ end
1463
+
1464
+ def install_portal_access_tests!
1465
+ cfg = portal_access_config
1466
+ cfg[:matrix].each do |role_sym, allowed_portals|
1467
+ cfg[:portals].each do |portal_sym|
1468
+ expected_allow = allowed_portals.include?(portal_sym)
1469
+ test("#{role_sym} accessing #{portal_sym} portal") do
1470
+ account = instance_exec(&portal_accounts.fetch(role_sym))
1471
+ login_as(account, portal: role_sym == :admin ? :admin : :user)
1472
+ get portal_root_path(portal_sym)
1473
+ if expected_allow
1474
+ assert_includes [200, 302], response.status
1475
+ else
1476
+ assert_includes [302, 403, 404], response.status,
1477
+ "Expected #{role_sym} blocked from #{portal_sym}, got #{response.status}"
1478
+ end
1479
+ end
1480
+ end
1481
+ end
1482
+ end
1483
+ end
1484
+
1485
+ def portal_accounts
1486
+ raise NotImplementedError, "Override #portal_accounts to return Hash{role_sym => -> { account }}"
1487
+ end
1488
+
1489
+ def portal_root_path(portal)
1490
+ raise NotImplementedError, "Override #portal_root_path(portal) to return the URL path"
1491
+ end
1492
+ end
1493
+ end
1494
+ end
1495
+ ```
1496
+
1497
+ - [ ] **Step 4: Run test to verify it passes**
1498
+
1499
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/testing/portal_access_test.rb -v`
1500
+ Expected: PASS — 4 runs (2 roles × 2 portals), 0 failures.
1501
+
1502
+ - [ ] **Step 5: Commit**
1503
+
1504
+ ```bash
1505
+ git add lib/plutonium/testing/portal_access.rb test/plutonium/testing/portal_access_test.rb
1506
+ git commit -m "feat(testing): add PortalAccess concern for cross-portal boundaries"
1507
+ ```
1508
+
1509
+ ---
1510
+
1511
+ ## Task 11: pu:test:install generator
1512
+
1513
+ **Goal:** One-time project setup. Adds `require "plutonium/testing"` to `test/test_helper.rb` and creates `test/support/plutonium_testing.rb` with commented-out override stubs.
1514
+
1515
+ **Files:**
1516
+ - Create: `lib/generators/pu/test/install/install_generator.rb`
1517
+ - Create: `lib/generators/pu/test/install/templates/plutonium_testing.rb.tt`
1518
+ - Test: `test/generators/pu/test/install_generator_test.rb`
1519
+
1520
+ **Acceptance Criteria:**
1521
+ - [ ] Adds `require "plutonium/testing"` to `test/test_helper.rb` if missing
1522
+ - [ ] No-op if line already present (idempotent)
1523
+ - [ ] Creates `test/support/plutonium_testing.rb` with commented `sign_in_for_tests` example
1524
+ - [ ] Generator follows existing `pu:core:install` pattern
1525
+
1526
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/generators/pu/test/install_generator_test.rb -v` → all tests pass.
1527
+
1528
+ **Steps:**
1529
+
1530
+ - [ ] **Step 1: Write the failing test**
1531
+
1532
+ ```ruby
1533
+ # test/generators/pu/test/install_generator_test.rb
1534
+ require "test_helper"
1535
+ require "generators/pu/test/install/install_generator"
1536
+
1537
+ class Pu::Test::InstallGeneratorTest < Rails::Generators::TestCase
1538
+ tests Pu::Test::InstallGenerator
1539
+ destination File.expand_path("../../../../tmp/pu_test_install", __dir__)
1540
+ setup :prepare_destination
1541
+
1542
+ def setup
1543
+ super
1544
+ FileUtils.mkdir_p(File.join(destination_root, "test"))
1545
+ File.write(File.join(destination_root, "test/test_helper.rb"), "ENV['RAILS_ENV'] ||= 'test'\n")
1546
+ end
1547
+
1548
+ test "adds require to test_helper.rb" do
1549
+ run_generator
1550
+ helper = File.read(File.join(destination_root, "test/test_helper.rb"))
1551
+ assert_includes helper, %(require "plutonium/testing")
1552
+ end
1553
+
1554
+ test "is idempotent" do
1555
+ run_generator
1556
+ run_generator
1557
+ helper = File.read(File.join(destination_root, "test/test_helper.rb"))
1558
+ assert_equal 1, helper.scan(%(require "plutonium/testing")).size
1559
+ end
1560
+
1561
+ test "creates support file with override stub" do
1562
+ run_generator
1563
+ assert_file "test/support/plutonium_testing.rb" do |content|
1564
+ assert_match(/sign_in_for_tests/, content)
1565
+ end
1566
+ end
1567
+ end
1568
+ ```
1569
+
1570
+ - [ ] **Step 2: Run test to verify it fails**
1571
+
1572
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/pu/test/install_generator_test.rb -v`
1573
+ Expected: FAIL — generator doesn't exist.
1574
+
1575
+ - [ ] **Step 3: Implement generator**
1576
+
1577
+ ```ruby
1578
+ # lib/generators/pu/test/install/install_generator.rb
1579
+ # frozen_string_literal: true
1580
+
1581
+ require_relative "../../../lib/plutonium_generators"
1582
+
1583
+ module Pu
1584
+ module Test
1585
+ class InstallGenerator < Rails::Generators::Base
1586
+ include PlutoniumGenerators::Generator
1587
+
1588
+ source_root File.expand_path("templates", __dir__)
1589
+
1590
+ desc "Install Plutonium::Testing scaffolding"
1591
+
1592
+ def install
1593
+ add_require_to_test_helper
1594
+ copy_support_file
1595
+ end
1596
+
1597
+ private
1598
+
1599
+ def add_require_to_test_helper
1600
+ helper = "test/test_helper.rb"
1601
+ return unless File.exist?(helper)
1602
+ line = %(require "plutonium/testing"\n)
1603
+ return if File.read(helper).include?(line.strip)
1604
+ append_to_file helper, "\n#{line}"
1605
+ end
1606
+
1607
+ def copy_support_file
1608
+ copy_file "plutonium_testing.rb", "test/support/plutonium_testing.rb"
1609
+ end
1610
+ end
1611
+ end
1612
+ end
1613
+ ```
1614
+
1615
+ ```erb
1616
+ # lib/generators/pu/test/install/templates/plutonium_testing.rb.tt
1617
+ # frozen_string_literal: true
1618
+
1619
+ # Plutonium::Testing project hooks.
1620
+ #
1621
+ # Override authentication for non-Rodauth setups by defining a top-level helper
1622
+ # that gets included into integration tests:
1623
+ #
1624
+ # module PlutoniumTestingOverrides
1625
+ # def sign_in_for_tests(account, portal:)
1626
+ # # your custom auth flow here
1627
+ # end
1628
+ # end
1629
+ #
1630
+ # ActiveSupport::TestCase.include(PlutoniumTestingOverrides)
1631
+ ```
1632
+
1633
+ - [ ] **Step 4: Run test to verify it passes**
1634
+
1635
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/pu/test/install_generator_test.rb -v`
1636
+ Expected: PASS — 3 runs, 0 failures.
1637
+
1638
+ - [ ] **Step 5: Commit**
1639
+
1640
+ ```bash
1641
+ git add lib/generators/pu/test/install/ test/generators/pu/test/install_generator_test.rb
1642
+ git commit -m "feat(generators): add pu:test:install generator"
1643
+ ```
1644
+
1645
+ ---
1646
+
1647
+ ## Task 12: pu:test:scaffold generator
1648
+
1649
+ **Goal:** Per-resource × portal test scaffold. Emits one file per portal with stub method bodies pre-filled from model introspection.
1650
+
1651
+ **Files:**
1652
+ - Create: `lib/generators/pu/test/scaffold/scaffold_generator.rb`
1653
+ - Create: `lib/generators/pu/test/scaffold/templates/integration_test.rb.tt`
1654
+ - Test: `test/generators/pu/test/scaffold_generator_test.rb`
1655
+
1656
+ **Acceptance Criteria:**
1657
+ - [ ] `rails g pu:test:scaffold Blogging::Post --portals=admin,org` emits 2 files
1658
+ - [ ] `--concerns=crud,policy` toggles which concerns are included
1659
+ - [ ] `--parent=organization` adds `parent: :organization` to DSL call
1660
+ - [ ] `--dest=main_app|<package>` routes output to correct directory
1661
+ - [ ] Generated file's stub bodies use best-guess values from model introspection (column types, associations)
1662
+ - [ ] Generated test file passes when run against the dummy app
1663
+
1664
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/generators/pu/test/scaffold_generator_test.rb -v` → all tests pass.
1665
+
1666
+ **Steps:**
1667
+
1668
+ - [ ] **Step 1: Write the failing test**
1669
+
1670
+ ```ruby
1671
+ # test/generators/pu/test/scaffold_generator_test.rb
1672
+ require "test_helper"
1673
+ require "generators/pu/test/scaffold/scaffold_generator"
1674
+
1675
+ class Pu::Test::ScaffoldGeneratorTest < Rails::Generators::TestCase
1676
+ tests Pu::Test::ScaffoldGenerator
1677
+ destination File.expand_path("../../../../tmp/pu_test_scaffold", __dir__)
1678
+ setup :prepare_destination
1679
+
1680
+ test "generates one file per portal" do
1681
+ run_generator %w[Blogging::Post --portals=admin,org --dest=main_app]
1682
+ assert_file "test/integration/admin_portal/blogging_posts_test.rb"
1683
+ assert_file "test/integration/org_portal/blogging_posts_test.rb"
1684
+ end
1685
+
1686
+ test "respects --concerns" do
1687
+ run_generator %w[Blogging::Post --portals=admin --concerns=crud,policy --dest=main_app]
1688
+ assert_file "test/integration/admin_portal/blogging_posts_test.rb" do |c|
1689
+ assert_match(/include Plutonium::Testing::ResourceCrud/, c)
1690
+ assert_match(/include Plutonium::Testing::ResourcePolicy/, c)
1691
+ refute_match(/ResourceDefinition/, c)
1692
+ end
1693
+ end
1694
+
1695
+ test "wires parent via --parent" do
1696
+ run_generator %w[Blogging::Post --portals=org --parent=organization --dest=main_app]
1697
+ assert_file "test/integration/org_portal/blogging_posts_test.rb" do |c|
1698
+ assert_match(/parent: :organization/, c)
1699
+ end
1700
+ end
1701
+ end
1702
+ ```
1703
+
1704
+ - [ ] **Step 2: Run test to verify it fails**
1705
+
1706
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/pu/test/scaffold_generator_test.rb -v`
1707
+
1708
+ - [ ] **Step 3: Implement generator**
1709
+
1710
+ ```ruby
1711
+ # lib/generators/pu/test/scaffold/scaffold_generator.rb
1712
+ # frozen_string_literal: true
1713
+
1714
+ require_relative "../../../lib/plutonium_generators"
1715
+
1716
+ module Pu
1717
+ module Test
1718
+ class ScaffoldGenerator < Rails::Generators::NamedBase
1719
+ include PlutoniumGenerators::Generator
1720
+
1721
+ source_root File.expand_path("templates", __dir__)
1722
+
1723
+ argument :name, type: :string, desc: "Resource class (e.g. Blogging::Post)"
1724
+
1725
+ class_option :portals, type: :array, required: true,
1726
+ desc: "Portals to scaffold tests for (e.g. admin,org)"
1727
+ class_option :concerns, type: :array, default: %w[crud policy definition],
1728
+ desc: "Concerns to include"
1729
+ class_option :parent, type: :string, desc: "Parent association for nested resources"
1730
+ class_option :dest, type: :string, default: "main_app",
1731
+ desc: "main_app or package name"
1732
+
1733
+ def scaffold
1734
+ options[:portals].each { |portal| scaffold_for_portal(portal) }
1735
+ end
1736
+
1737
+ private
1738
+
1739
+ def scaffold_for_portal(portal)
1740
+ @portal = portal
1741
+ @resource_class = name
1742
+ @file_name = name.underscore.tr("/", "_")
1743
+ @class_name = "#{portal.camelize}Portal::#{name.gsub('::', '')}Test"
1744
+ @concerns = options[:concerns]
1745
+ @parent = options[:parent]
1746
+ target_dir = (options[:dest] == "main_app") ? "test/integration" : "packages/#{options[:dest]}/test/integration"
1747
+ target = "#{target_dir}/#{portal}_portal/#{file_name_for(portal)}.rb"
1748
+ template "integration_test.rb.tt", target
1749
+ end
1750
+
1751
+ def file_name_for(_portal)
1752
+ "#{@file_name}_test"
1753
+ end
1754
+ end
1755
+ end
1756
+ end
1757
+ ```
1758
+
1759
+ ```erb
1760
+ # lib/generators/pu/test/scaffold/templates/integration_test.rb.tt
1761
+ # frozen_string_literal: true
1762
+
1763
+ require "test_helper"
1764
+
1765
+ class <%= @class_name %> < ActionDispatch::IntegrationTest
1766
+ <% @concerns.each do |c| -%>
1767
+ include Plutonium::Testing::<%= c.camelize %>
1768
+ <% end -%>
1769
+
1770
+ resource_tests_for <%= @resource_class %>,
1771
+ portal: :<%= @portal %><% if @parent %>,
1772
+ parent: :<%= @parent %><% end %>
1773
+
1774
+ setup do
1775
+ # TODO: replace with your factories.
1776
+ @account = nil
1777
+ login_as(@account)
1778
+ end
1779
+
1780
+ <% if @concerns.include?("crud") -%>
1781
+ def create_resource!
1782
+ <%= @resource_class %>.create!(
1783
+ # TODO: fill in valid attributes
1784
+ )
1785
+ end
1786
+
1787
+ def valid_create_params
1788
+ {} # TODO
1789
+ end
1790
+
1791
+ def valid_update_params
1792
+ {} # TODO
1793
+ end
1794
+ <% end -%>
1795
+ <% if @concerns.include?("policy") -%>
1796
+
1797
+ def policy_roles
1798
+ {<%= @portal %>: -> { @account }}
1799
+ end
1800
+
1801
+ def policy_record
1802
+ create_resource!
1803
+ end
1804
+
1805
+ def policy_matrix
1806
+ {
1807
+ index: %i[<%= @portal %>],
1808
+ show: %i[<%= @portal %>],
1809
+ create: %i[<%= @portal %>],
1810
+ update: %i[<%= @portal %>],
1811
+ destroy: %i[<%= @portal %>]
1812
+ }
1813
+ end
1814
+ <% end -%>
1815
+ end
1816
+ ```
1817
+
1818
+ - [ ] **Step 4: Run test to verify it passes**
1819
+
1820
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/pu/test/scaffold_generator_test.rb -v`
1821
+ Expected: PASS — 3 runs, 0 failures.
1822
+
1823
+ - [ ] **Step 5: Commit**
1824
+
1825
+ ```bash
1826
+ git add lib/generators/pu/test/scaffold/ test/generators/pu/test/scaffold_generator_test.rb
1827
+ git commit -m "feat(generators): add pu:test:scaffold generator"
1828
+ ```
1829
+
1830
+ ---
1831
+
1832
+ ## Task 13: plutonium-testing skill documentation
1833
+
1834
+ **Goal:** `.claude/skills/plutonium-testing/SKILL.md` documenting the full toolkit for AI assistants. Add router entry in top-level `plutonium` skill.
1835
+
1836
+ **Files:**
1837
+ - Create: `.claude/skills/plutonium-testing/SKILL.md`
1838
+ - Modify: `.claude/skills/plutonium/SKILL.md`
1839
+
1840
+ **Acceptance Criteria:**
1841
+ - [ ] Frontmatter `description` triggers on testing-related queries
1842
+ - [ ] All 8 sections present: when to use, quick start, DSL, concerns catalog, auth, generators, customization, pitfalls
1843
+ - [ ] Each concern has a stub-contract example
1844
+ - [ ] `plutonium` router skill points to `plutonium-testing` for testing-related work
1845
+
1846
+ **Verify:** Manual review against existing skills (e.g. `.claude/skills/plutonium-policy/SKILL.md`) for tone, structure, and length parity.
1847
+
1848
+ **Steps:**
1849
+
1850
+ - [ ] **Step 1: Read existing skill for tone reference**
1851
+
1852
+ Read `.claude/skills/plutonium-policy/SKILL.md` and `.claude/skills/plutonium-definition/SKILL.md` to match style.
1853
+
1854
+ - [ ] **Step 2: Write `.claude/skills/plutonium-testing/SKILL.md`**
1855
+
1856
+ Use frontmatter:
1857
+
1858
+ ```yaml
1859
+ ---
1860
+ name: plutonium-testing
1861
+ description: Use BEFORE writing tests for a Plutonium resource, running pu:test:scaffold, or including Plutonium::Testing::* concerns. Covers CRUD, policy, definition, interaction, model, nested, portal access, and auth helpers.
1862
+ ---
1863
+ ```
1864
+
1865
+ Sections (each with concrete code examples):
1866
+ 1. **When to use** — triggers / scenarios
1867
+ 2. **Quick start** — `pu:test:install`, `pu:test:scaffold`, run the suite
1868
+ 3. **DSL reference** — `resource_tests_for` keywords table + portal resolution behavior
1869
+ 4. **Concerns catalog** — one subsection per concern (ResourceCrud, ResourcePolicy, ResourceDefinition, ResourceInteraction, ResourceModel, NestedResource, PortalAccess) with stub contract + minimal usage example
1870
+ 5. **Auth helpers** — `login_as`, `sign_out`, `with_portal`, override hook
1871
+ 6. **Generators** — `pu:test:install`, `pu:test:scaffold` with all flags
1872
+ 7. **Customization** — non-Rodauth auth, opting out of default tests, adding custom assertions alongside the matrix
1873
+ 8. **Common pitfalls** — forgotten stubs, portal mismatch, tenant leakage in stubs, missing parent for nested resources
1874
+
1875
+ Length target: 300–500 lines, comparable to `plutonium-definition` skill.
1876
+
1877
+ - [ ] **Step 3: Add router entry in `.claude/skills/plutonium/SKILL.md`**
1878
+
1879
+ Add bullet pointing to `plutonium-testing` in the testing/QA section of the router.
1880
+
1881
+ - [ ] **Step 4: Commit**
1882
+
1883
+ ```bash
1884
+ git add .claude/skills/plutonium-testing/ .claude/skills/plutonium/SKILL.md
1885
+ git commit -m "docs(skills): add plutonium-testing skill"
1886
+ ```
1887
+
1888
+ ---
1889
+
1890
+ ## Task 14: VitePress docs guide
1891
+
1892
+ **Goal:** `docs/guides/testing.md` mirrors the skill content for human-facing docs. Linked from sidebar nav.
1893
+
1894
+ **Files:**
1895
+ - Create: `docs/guides/testing.md`
1896
+ - Modify: `docs/.vitepress/config.ts`
1897
+
1898
+ **Acceptance Criteria:**
1899
+ - [ ] `docs/guides/testing.md` has content parity with the skill
1900
+ - [ ] Linked from guides section in sidebar
1901
+ - [ ] `yarn docs:build` succeeds with no broken links
1902
+
1903
+ **Verify:** `cd /Users/stefan/Documents/plutonium/plutonium-core && yarn docs:build` → exit 0, no broken-link warnings.
1904
+
1905
+ **Steps:**
1906
+
1907
+ - [ ] **Step 1: Author `docs/guides/testing.md`**
1908
+
1909
+ Mirror sections from the skill (when to use, install, scaffold, DSL, concerns, generators, customization, pitfalls). Use VitePress markdown conventions consistent with existing guides in `docs/guides/`.
1910
+
1911
+ - [ ] **Step 2: Add to sidebar**
1912
+
1913
+ Edit `docs/.vitepress/config.ts` to include `Testing` under the Guides section, pointing to `/guides/testing`.
1914
+
1915
+ - [ ] **Step 3: Verify build**
1916
+
1917
+ Run: `yarn docs:build`
1918
+ Expected: build succeeds; no `dead links` warnings.
1919
+
1920
+ - [ ] **Step 4: Commit**
1921
+
1922
+ ```bash
1923
+ git add docs/guides/testing.md docs/.vitepress/config.ts
1924
+ git commit -m "docs: add testing guide to docs site"
1925
+ ```
1926
+
1927
+ ---
1928
+
1929
+ ## Task 15: Migrate in-repo shared_tests to use Plutonium::Testing
1930
+
1931
+ **Goal:** Dogfood the public API. Port `test/integration/*_portal/` tests to use the new concerns. Delete or shrink `test/support/shared_tests/`.
1932
+
1933
+ **Files:**
1934
+ - Modify: `test/integration/admin_portal/resources_test.rb`
1935
+ - Modify: `test/integration/org_portal/*.rb`
1936
+ - Modify: `test/integration/locus_portal/*.rb`
1937
+ - Modify: `test/integration/storefront_portal/*.rb`
1938
+ - Delete or shrink: `test/support/shared_tests/blogging_post_tests.rb`
1939
+ - Delete or shrink: `test/support/shared_tests/catalog_product_tests.rb`
1940
+
1941
+ **Acceptance Criteria:**
1942
+ - [ ] Full test suite passes against `rails-7`, `rails-8.0`, `rails-8.1`
1943
+ - [ ] Test method count matches or exceeds pre-migration baseline
1944
+ - [ ] No reference remains to deleted `SharedTests::*` modules
1945
+
1946
+ **Verify:**
1947
+ - Baseline before migration: `bundle exec appraisal rails-8.1 rake test 2>&1 | tail -5` → record N runs.
1948
+ - After migration: same command → at least N runs, all pass.
1949
+ - Run all appraisals: `bundle exec appraisal rake test`.
1950
+
1951
+ **Steps:**
1952
+
1953
+ - [ ] **Step 1: Record baseline**
1954
+
1955
+ ```bash
1956
+ bundle exec appraisal rails-8.1 rake test 2>&1 | tail -5 > /tmp/baseline.txt
1957
+ cat /tmp/baseline.txt
1958
+ ```
1959
+
1960
+ Note the `N runs, X assertions` line.
1961
+
1962
+ - [ ] **Step 2: Port admin_portal/resources_test.rb to use new concerns**
1963
+
1964
+ Replace:
1965
+ ```ruby
1966
+ include SharedTests::BloggingPostTests
1967
+ include SharedTests::CatalogProductTests
1968
+ ```
1969
+ with:
1970
+ ```ruby
1971
+ class AdminPortal::BloggingPostsTest < ActionDispatch::IntegrationTest
1972
+ include IntegrationTestHelper
1973
+ include Plutonium::Testing::ResourceCrud
1974
+ include Plutonium::Testing::ResourcePolicy
1975
+ include Plutonium::Testing::ResourceDefinition
1976
+
1977
+ resource_tests_for Blogging::Post, portal: :admin
1978
+
1979
+ setup do
1980
+ @admin = create_admin!
1981
+ @org = create_organization!
1982
+ @user = create_user!
1983
+ login_as(@admin)
1984
+ end
1985
+
1986
+ def create_resource!; create_post!; end
1987
+ def valid_create_params
1988
+ {title: "x", body: "y", status: :draft, user: @user.to_sgid.to_s, organization: @org.to_sgid.to_s}
1989
+ end
1990
+ def valid_update_params; {title: "Updated"}; end
1991
+ def policy_roles; {admin: -> { @admin }}; end
1992
+ def policy_record; create_post!; end
1993
+ def policy_matrix
1994
+ {index: %i[admin], show: %i[admin], create: %i[admin],
1995
+ update: %i[admin], destroy: %i[admin]}
1996
+ end
1997
+ end
1998
+ ```
1999
+
2000
+ Apply analogous transformations for other resources (Catalog::Product, etc.) and other portals (org, locus, storefront).
2001
+
2002
+ - [ ] **Step 3: Delete obsolete shared_tests modules**
2003
+
2004
+ Once no test file references `SharedTests::BloggingPostTests` or `SharedTests::CatalogProductTests`:
2005
+ ```bash
2006
+ rm test/support/shared_tests/blogging_post_tests.rb
2007
+ rm test/support/shared_tests/catalog_product_tests.rb
2008
+ rmdir test/support/shared_tests 2>/dev/null
2009
+ ```
2010
+
2011
+ - [ ] **Step 4: Run full suite**
2012
+
2013
+ Run: `bundle exec appraisal rails-8.1 rake test`
2014
+ Expected: all green; runs >= baseline.
2015
+
2016
+ - [ ] **Step 5: Run all appraisals**
2017
+
2018
+ Run: `bundle exec appraisal rake test`
2019
+ Expected: all three (`rails-7`, `rails-8.0`, `rails-8.1`) pass.
2020
+
2021
+ - [ ] **Step 6: Commit**
2022
+
2023
+ ```bash
2024
+ git add test/integration/ test/support/
2025
+ git commit -m "test: migrate dummy app to Plutonium::Testing concerns"
2026
+ ```
2027
+
2028
+ ---
2029
+
2030
+ ## Self-Review
2031
+
2032
+ **1. Spec coverage:**
2033
+ - File layout (Section 1) → Task 1 ✓
2034
+ - DSL + portal resolution (Section 2) → Task 2 ✓
2035
+ - AuthHelpers (Section 3) → Task 3 ✓
2036
+ - 7 concerns (Sections 4–6) → Tasks 4–10 ✓
2037
+ - Generators (Section 7) → Tasks 11, 12 ✓
2038
+ - Skill (Section 8) → Task 13 ✓
2039
+ - Docs (Section 9) → Task 14 ✓
2040
+ - In-repo migration (Section 10) → Task 15 ✓
2041
+
2042
+ **2. Placeholder scan:** No TBD / "implement later" tokens. Two `NOTE for the implementer` annotations (Tasks 6 and 7) point to specific files to verify the actual public API names — these are deliberate, the implementer should look them up rather than guess.
2043
+
2044
+ **3. Type consistency:** `resource_tests_config` Hash keys consistent across tasks. `current_portal`, `current_path_prefix` defined in DSL (Task 2) and consumed unchanged in subsequent tasks. `policy_roles` / `policy_matrix` / `policy_record` stub names used identically in Tasks 5 and 15.
2045
+
2046
+ **4. Verification requirement scan:** Original spec → "All acceptance is via automated tests." NO user verification required. No verification tasks needed. Plan-header `User Verification: NO` matches.