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,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Testing
5
+ module DSL
6
+ extend ActiveSupport::Concern
7
+
8
+ class PortalNotFound < StandardError; end
9
+
10
+ DEFAULT_ACTIONS = %i[index show new create edit update destroy].freeze
11
+
12
+ class_methods do
13
+ def resource_tests_for(resource_class, portal:, path_prefix: nil, parent: nil,
14
+ actions: DEFAULT_ACTIONS, skip: [],
15
+ associated_with: nil, sgid_routing: false, has_cents: [])
16
+ @resource_tests_config = {
17
+ resource: resource_class,
18
+ portal: portal,
19
+ path_prefix: path_prefix || resolve_portal_path_prefix(portal),
20
+ parent: parent,
21
+ actions: actions,
22
+ skip: skip,
23
+ associated_with: associated_with,
24
+ sgid_routing: sgid_routing,
25
+ has_cents: has_cents
26
+ }
27
+ end
28
+
29
+ def resource_tests_config
30
+ @resource_tests_config or raise "resource_tests_for not called on #{name}"
31
+ end
32
+
33
+ private
34
+
35
+ def resolve_portal_path_prefix(portal_sym)
36
+ engine_name = "#{portal_sym.to_s.camelize}Portal::Engine"
37
+ engine_const = engine_name.safe_constantize
38
+ unless engine_const
39
+ raise PortalNotFound, "Could not resolve portal :#{portal_sym} (looked for #{engine_name})"
40
+ end
41
+
42
+ mount = find_engine_mount(engine_const)
43
+ unless mount
44
+ raise PortalNotFound, "Engine #{engine_const} is not mounted in routes"
45
+ end
46
+
47
+ mount.path.spec.to_s.sub(/\(\.:format\)\z/, "").chomp("/")
48
+ end
49
+
50
+ def find_engine_mount(engine_const)
51
+ Rails.application.routes.routes.find do |route|
52
+ matches_engine?(route.app, engine_const)
53
+ end
54
+ end
55
+
56
+ def matches_engine?(app, engine_const)
57
+ return true if app == engine_const
58
+ return false unless app.respond_to?(:app)
59
+ return false if app.app == app
60
+ matches_engine?(app.app, engine_const)
61
+ end
62
+ end
63
+
64
+ def current_portal
65
+ @__portal_override || self.class.resource_tests_config.fetch(:portal)
66
+ end
67
+
68
+ def current_path_prefix
69
+ self.class.resource_tests_config.fetch(:path_prefix)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "plutonium/testing/dsl"
4
+ require "plutonium/testing/auth_helpers"
5
+
6
+ module Plutonium
7
+ module Testing
8
+ module NestedResource
9
+ extend ActiveSupport::Concern
10
+ include Plutonium::Testing::DSL
11
+ include Plutonium::Testing::AuthHelpers
12
+
13
+ class_methods do
14
+ def resource_tests_for(*args, **kwargs)
15
+ super
16
+ install_nested_tests!
17
+ end
18
+
19
+ def install_nested_tests!
20
+ test "nested: index lists records from current parent" do
21
+ create_resource!(parent: parent_record!)
22
+ get scoped_index_path(parent_record!)
23
+ assert_response :success
24
+ end
25
+
26
+ test "nested: show on sibling-tenant record returns 404" do
27
+ sibling = create_resource!(parent: other_parent_record!)
28
+ get "#{scoped_index_path(parent_record!)}/#{sibling.id}"
29
+ assert_includes [404, 302], response.status,
30
+ "Expected sibling-tenant record to be inaccessible (404 or redirect), got #{response.status}"
31
+ end
32
+ end
33
+ end
34
+
35
+ def parent_record!
36
+ raise NotImplementedError, "Override #parent_record! to return the current tenant"
37
+ end
38
+
39
+ def other_parent_record!
40
+ raise NotImplementedError, "Override #other_parent_record! to return a sibling tenant"
41
+ end
42
+
43
+ def create_resource!(parent:)
44
+ raise NotImplementedError, "Override #create_resource!(parent:) to return a persisted record under the given parent"
45
+ end
46
+
47
+ private
48
+
49
+ def scoped_index_path(parent)
50
+ "#{current_path_prefix}/#{parent.id}/#{resource_collection}"
51
+ end
52
+
53
+ def resource_collection
54
+ self.class.resource_tests_config.fetch(:resource).model_name.collection
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "plutonium/testing/auth_helpers"
4
+
5
+ module Plutonium
6
+ module Testing
7
+ module PortalAccess
8
+ extend ActiveSupport::Concern
9
+ include Plutonium::Testing::AuthHelpers
10
+
11
+ class_methods do
12
+ attr_reader :portal_access_config
13
+
14
+ def portal_access_for(portals:, matrix:)
15
+ @portal_access_config = {portals: portals, matrix: matrix}
16
+ install_portal_access_tests!
17
+ end
18
+
19
+ def install_portal_access_tests!
20
+ cfg = portal_access_config
21
+ cfg[:matrix].each do |role_sym, allowed_portals|
22
+ cfg[:portals].each do |portal_sym|
23
+ expected_allow = allowed_portals.include?(portal_sym)
24
+ test "portal access: #{role_sym} -> #{portal_sym} (#{expected_allow ? "allowed" : "blocked"})" do
25
+ login_as_role(role_sym)
26
+ get portal_root_path(portal_sym)
27
+ if expected_allow
28
+ assert_includes [200, 302], response.status,
29
+ "Expected #{role_sym} to access #{portal_sym}, got #{response.status}"
30
+ else
31
+ assert_includes [302, 401, 403, 404], response.status,
32
+ "Expected #{role_sym} blocked from #{portal_sym}, got #{response.status}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ def login_as_role(role_sym)
41
+ raise NotImplementedError, "Override #login_as_role(role_sym) to log in the given role"
42
+ end
43
+
44
+ def portal_root_path(portal_sym)
45
+ raise NotImplementedError, "Override #portal_root_path(portal_sym) to return the URL"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "plutonium/testing/dsl"
4
+ require "plutonium/testing/auth_helpers"
5
+
6
+ module Plutonium
7
+ module Testing
8
+ module ResourceCrud
9
+ extend ActiveSupport::Concern
10
+ include Plutonium::Testing::DSL
11
+ include Plutonium::Testing::AuthHelpers
12
+
13
+ class_methods do
14
+ def resource_tests_for(*args, **kwargs)
15
+ super
16
+ install_crud_tests!
17
+ end
18
+
19
+ def install_crud_tests!
20
+ define_crud_test :index do
21
+ create_resource!
22
+ get "#{current_path_prefix}/#{resource_path}"
23
+ assert_response :success
24
+ end
25
+
26
+ define_crud_test :show do
27
+ record = create_resource!
28
+ get "#{current_path_prefix}/#{resource_path}/#{record.id}"
29
+ assert_response :success
30
+ end
31
+
32
+ define_crud_test :new do
33
+ get "#{current_path_prefix}/#{resource_path}/new"
34
+ assert_response :success
35
+ end
36
+
37
+ define_crud_test :create do
38
+ assert_difference -> { resource_class.count }, 1 do
39
+ post "#{current_path_prefix}/#{resource_path}", params: {param_key => valid_create_params}
40
+ end
41
+ assert_response :redirect
42
+ end
43
+
44
+ define_crud_test :edit do
45
+ record = create_resource!
46
+ get "#{current_path_prefix}/#{resource_path}/#{record.id}/edit"
47
+ assert_response :success
48
+ end
49
+
50
+ define_crud_test :update do
51
+ record = create_resource!
52
+ patch "#{current_path_prefix}/#{resource_path}/#{record.id}", params: {param_key => valid_update_params}
53
+ assert_response :redirect
54
+ valid_update_params.each do |attr, value|
55
+ next if value.is_a?(String) && value.start_with?("gid://") # skip SGID assoc fields
56
+ assert_equal value, record.reload.public_send(attr),
57
+ "Expected ##{attr} to be updated to #{value.inspect}"
58
+ end
59
+ end
60
+
61
+ define_crud_test :destroy do
62
+ record = create_resource!
63
+ assert_difference -> { resource_class.count }, -1 do
64
+ delete "#{current_path_prefix}/#{resource_path}/#{record.id}"
65
+ end
66
+ end
67
+ end
68
+
69
+ def define_crud_test(action, &block)
70
+ cfg = resource_tests_config
71
+ return unless cfg[:actions].include?(action)
72
+ return if cfg[:skip].include?(action)
73
+ test("crud: #{action}", &block)
74
+ end
75
+ end
76
+
77
+ def create_resource!
78
+ raise NotImplementedError, "Override #create_resource! to return a persisted record"
79
+ end
80
+
81
+ def valid_create_params
82
+ raise NotImplementedError, "Override #valid_create_params to return a Hash of valid attributes for POST"
83
+ end
84
+
85
+ def valid_update_params
86
+ raise NotImplementedError, "Override #valid_update_params to return a Hash of valid attributes for PATCH"
87
+ end
88
+
89
+ private
90
+
91
+ def resource_class
92
+ self.class.resource_tests_config.fetch(:resource)
93
+ end
94
+
95
+ def resource_path
96
+ resource_class.model_name.collection
97
+ end
98
+
99
+ def param_key
100
+ resource_class.model_name.param_key
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "plutonium/testing/dsl"
4
+
5
+ module Plutonium
6
+ module Testing
7
+ module ResourceDefinition
8
+ extend ActiveSupport::Concern
9
+ include Plutonium::Testing::DSL
10
+
11
+ class_methods do
12
+ def resource_tests_for(*args, **kwargs)
13
+ super
14
+ install_definition_tests!
15
+ end
16
+
17
+ def install_definition_tests!
18
+ test "definition: class is constantize-able" do
19
+ assert definition_class, "Expected #{resource_class}Definition to exist"
20
+ end
21
+
22
+ test "definition: every defineable prop dictionary is queryable" do
23
+ klass = definition_class
24
+ klass._defineable_props_store.each do |prop_plural|
25
+ dict = klass.public_send("defined_#{prop_plural}")
26
+ assert dict.is_a?(Hash), "defined_#{prop_plural} must be Hash, got #{dict.class}"
27
+ end
28
+ end
29
+
30
+ test "definition: declared fields exist on the model" do
31
+ klass = definition_class
32
+ return unless klass.respond_to?(:defined_fields)
33
+ klass.defined_fields.each_key do |field_name|
34
+ next if field_name == :id
35
+ assert resource_class.column_names.include?(field_name.to_s) ||
36
+ resource_class.method_defined?(field_name) ||
37
+ resource_class.reflect_on_association(field_name),
38
+ "Field :#{field_name} declared in #{klass} but not defined on #{resource_class}"
39
+ end
40
+ end
41
+ end
42
+
43
+ def resource_class
44
+ resource_tests_config.fetch(:resource)
45
+ end
46
+
47
+ def definition_class
48
+ @definition_class ||= "#{resource_class.name}Definition".constantize
49
+ end
50
+ end
51
+
52
+ def resource_class
53
+ self.class.resource_class
54
+ end
55
+
56
+ def definition_class
57
+ self.class.definition_class
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Testing
5
+ module ResourceInteraction
6
+ extend ActiveSupport::Concern
7
+
8
+ class MockViewContext
9
+ def controller = @controller ||= MockController.new
10
+
11
+ class MockController
12
+ def helpers = @helpers ||= MockHelpers.new
13
+
14
+ class MockHelpers
15
+ def current_user = nil
16
+ end
17
+ end
18
+ end
19
+
20
+ def assert_interaction_success(klass, **input)
21
+ outcome = build_interaction(klass, **input).call
22
+ assert outcome.success?, "Expected #{klass} to succeed, got #{outcome.inspect}"
23
+ outcome
24
+ end
25
+
26
+ def assert_interaction_failure(klass, **input)
27
+ outcome = build_interaction(klass, **input).call
28
+ assert outcome.failure?, "Expected #{klass} to fail, got #{outcome.inspect}"
29
+ outcome
30
+ end
31
+
32
+ def interaction_view_context
33
+ MockViewContext.new
34
+ end
35
+
36
+ def interaction_class
37
+ raise NotImplementedError, "Override #interaction_class to return the interaction under test"
38
+ end
39
+
40
+ def valid_interaction_input
41
+ raise NotImplementedError, "Override #valid_interaction_input to return a Hash of valid input"
42
+ end
43
+
44
+ private
45
+
46
+ def build_interaction(klass, **input)
47
+ klass.new(view_context: interaction_view_context, **input)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "plutonium/testing/dsl"
4
+
5
+ module Plutonium
6
+ module Testing
7
+ module ResourceModel
8
+ extend ActiveSupport::Concern
9
+ include Plutonium::Testing::DSL
10
+
11
+ class_methods do
12
+ def resource_tests_for(*args, **kwargs)
13
+ super
14
+ install_model_tests!
15
+ end
16
+
17
+ def install_model_tests!
18
+ cfg = resource_tests_config
19
+
20
+ if (assoc = cfg[:associated_with])
21
+ test "model: associated_with(#{assoc}) scope filters records" do
22
+ record = model_test_record
23
+ parent = record.public_send(assoc)
24
+ scoped = record.class.associated_with(parent)
25
+ assert_includes scoped.to_a, record
26
+ end
27
+ end
28
+
29
+ if cfg[:sgid_routing]
30
+ test "model: SGID round-trip locates record" do
31
+ record = model_test_record
32
+ sgid = record.to_sgid.to_s
33
+ found = GlobalID::Locator.locate_signed(sgid)
34
+ assert_equal record, found
35
+ end
36
+ end
37
+
38
+ Array(cfg[:has_cents]).each do |attr|
39
+ test "model: has_cents :#{attr} provides cents accessor" do
40
+ record = model_test_record
41
+ assert record.respond_to?(attr), "Expected ##{attr}"
42
+ assert record.respond_to?("#{attr}_cents"), "Expected ##{attr}_cents"
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def model_test_record
49
+ raise NotImplementedError, "Override #model_test_record to return a persisted record"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "plutonium/testing/dsl"
4
+
5
+ module Plutonium
6
+ module Testing
7
+ module ResourcePolicy
8
+ extend ActiveSupport::Concern
9
+ include Plutonium::Testing::DSL
10
+
11
+ class_methods do
12
+ def resource_tests_for(*args, **kwargs)
13
+ super
14
+ install_policy_tests!
15
+ end
16
+
17
+ def install_policy_tests!
18
+ test "policy: matrix is asserted for every (action × role)" do
19
+ matrix = policy_matrix
20
+ roles = policy_roles
21
+ record = policy_record
22
+ policy_klass = policy_class_for(record)
23
+
24
+ matrix.each do |action, allowed_roles|
25
+ roles.each do |role_sym, account_proc|
26
+ account = instance_exec(&account_proc)
27
+ policy = policy_klass.new(record: record, user: account, **policy_context)
28
+ expected = allowed_roles.include?(role_sym)
29
+ actual = policy.public_send("#{action}?")
30
+ assert_equal expected, actual,
31
+ "#{policy_klass}#{action}? for #{role_sym}: expected #{expected}, got #{actual}"
32
+ end
33
+ end
34
+ end
35
+
36
+ test "policy: relation_scope returns AR::Relation per role" do
37
+ record = policy_record
38
+ policy_klass = policy_class_for(record)
39
+ policy_roles.each do |role_sym, account_proc|
40
+ account = instance_exec(&account_proc)
41
+ policy = policy_klass.new(record: record.class, user: account, **policy_context)
42
+ scope = policy.apply_scope(record.class.all, type: :active_record_relation)
43
+ assert_kind_of ActiveRecord::Relation, scope, "relation_scope must return AR::Relation for #{role_sym}"
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ def policy_roles
50
+ raise NotImplementedError, "Override #policy_roles to return Hash{role_sym => -> { account }}"
51
+ end
52
+
53
+ def policy_record
54
+ raise NotImplementedError, "Override #policy_record to return a persisted record"
55
+ end
56
+
57
+ def policy_matrix
58
+ raise NotImplementedError, "Override #policy_matrix to return Hash{action_sym => [role_syms]}"
59
+ end
60
+
61
+ def policy_context
62
+ {entity_scope: nil}
63
+ end
64
+
65
+ private
66
+
67
+ def policy_class_for(record)
68
+ "#{record.class.name}Policy".constantize
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Testing
5
+ end
6
+ end
7
+
8
+ require "plutonium/testing/dsl"
9
+ require "plutonium/testing/auth_helpers"
10
+ require "plutonium/testing/resource_crud"
11
+ require "plutonium/testing/resource_policy"
12
+ require "plutonium/testing/resource_definition"
13
+ require "plutonium/testing/resource_interaction"
14
+ require "plutonium/testing/resource_model"
15
+ require "plutonium/testing/nested_resource"
16
+ require "plutonium/testing/portal_access"
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.46.0"
2
+ VERSION = "0.47.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/lib/plutonium.rb CHANGED
@@ -27,6 +27,8 @@ module Plutonium
27
27
  Loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false).tap do |loader|
28
28
  loader.ignore("#{__dir__}/generators")
29
29
  loader.ignore("#{__dir__}/plutonium/railtie.rb")
30
+ loader.ignore("#{__dir__}/plutonium/testing.rb")
31
+ loader.ignore("#{__dir__}/plutonium/testing")
30
32
  loader.ignore("#{__dir__}/rodauth")
31
33
  loader.inflector.inflect("ui" => "UI")
32
34
  loader.inflector.inflect("workflow_dsl" => "WorkflowDSL")
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",