plutonium 0.46.0 → 0.48.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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +4 -0
- data/.claude/skills/plutonium-interaction/SKILL.md +23 -0
- data/.claude/skills/plutonium-nested-resources/SKILL.md +10 -0
- data/.claude/skills/plutonium-testing/SKILL.md +268 -0
- data/.yarnrc.yml +1 -0
- data/CHANGELOG.md +23 -0
- data/Rakefile +10 -1
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/config.ts +6 -0
- data/docs/guides/nested-resources.md +10 -0
- data/docs/guides/testing.md +154 -0
- data/docs/reference/controller/index.md +9 -4
- data/docs/superpowers/plans/2026-04-14-plutonium-testing.md +2046 -0
- data/docs/superpowers/plans/2026-04-14-plutonium-testing.md.tasks.json +21 -0
- data/docs/superpowers/specs/2026-04-14-plutonium-testing-design.md +364 -0
- data/gemfiles/rails_8.1.gemfile.lock +27 -1
- data/lib/generators/pu/test/install/install_generator.rb +34 -0
- data/lib/generators/pu/test/install/templates/plutonium_testing.rb.tt +14 -0
- data/lib/generators/pu/test/scaffold/scaffold_generator.rb +55 -0
- data/lib/generators/pu/test/scaffold/templates/integration_test.rb.tt +65 -0
- data/lib/plutonium/action/interactive.rb +2 -1
- data/lib/plutonium/core/controller.rb +18 -1
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +20 -1
- data/lib/plutonium/testing/auth_helpers.rb +62 -0
- data/lib/plutonium/testing/dsl.rb +73 -0
- data/lib/plutonium/testing/nested_resource.rb +58 -0
- data/lib/plutonium/testing/portal_access.rb +49 -0
- data/lib/plutonium/testing/resource_crud.rb +104 -0
- data/lib/plutonium/testing/resource_definition.rb +61 -0
- data/lib/plutonium/testing/resource_interaction.rb +51 -0
- data/lib/plutonium/testing/resource_model.rb +53 -0
- data/lib/plutonium/testing/resource_policy.rb +72 -0
- data/lib/plutonium/testing.rb +16 -0
- data/lib/plutonium/ui/action_button.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +2 -0
- data/package.json +1 -1
- data/plutonium.gemspec +2 -0
- data/yarn.lock +6037 -3893
- metadata +50 -2
|
@@ -87,11 +87,17 @@ module Plutonium
|
|
|
87
87
|
#
|
|
88
88
|
# @return [Hash] args to pass to `url_for`
|
|
89
89
|
#
|
|
90
|
-
def resource_url_args_for(*args, action: nil, parent: nil, association: nil, package: nil, **kwargs)
|
|
90
|
+
def resource_url_args_for(*args, action: nil, parent: nil, association: nil, package: nil, interaction: nil, **kwargs)
|
|
91
91
|
element = args.first
|
|
92
92
|
|
|
93
93
|
raise ArgumentError, "parent is required when using symbol association name" if element.is_a?(Symbol) && parent.nil?
|
|
94
94
|
|
|
95
|
+
if interaction
|
|
96
|
+
raise ArgumentError, "cannot pass both `interaction:` and `action:`" if action
|
|
97
|
+
action = interactive_action_type_for(element, ids: kwargs[:ids])
|
|
98
|
+
kwargs[:interactive_action] = interaction
|
|
99
|
+
end
|
|
100
|
+
|
|
95
101
|
# For nested resources, use named route helpers to avoid Rails param recall ambiguity
|
|
96
102
|
if parent.present?
|
|
97
103
|
assoc_name = if element.is_a?(Symbol)
|
|
@@ -137,6 +143,17 @@ module Plutonium
|
|
|
137
143
|
|
|
138
144
|
private
|
|
139
145
|
|
|
146
|
+
# Determine the interactive action type for the given element.
|
|
147
|
+
# Records → :interactive_record_action, classes/symbols with :ids → :interactive_bulk_action,
|
|
148
|
+
# otherwise :interactive_resource_action.
|
|
149
|
+
def interactive_action_type_for(element, ids: nil)
|
|
150
|
+
if element.is_a?(Class) || element.is_a?(Symbol) || element.nil?
|
|
151
|
+
ids.present? ? :interactive_bulk_action : :interactive_resource_action
|
|
152
|
+
else
|
|
153
|
+
:interactive_record_action
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
140
157
|
def build_nested_resource_url_args(element, parent:, association_name:, route_config:, action: nil, **kwargs)
|
|
141
158
|
prefix = Plutonium::Routing::NESTED_ROUTE_PREFIX
|
|
142
159
|
is_singular = route_config[:route_type] == :resource
|
|
@@ -2,7 +2,26 @@ module Plutonium
|
|
|
2
2
|
module Helpers
|
|
3
3
|
module TurboStreamActionsHelper
|
|
4
4
|
def turbo_stream_redirect(url)
|
|
5
|
-
|
|
5
|
+
if turbo_stream_redirect_same_page?(url)
|
|
6
|
+
turbo_stream_action_tag :refresh
|
|
7
|
+
else
|
|
8
|
+
turbo_stream_action_tag :redirect, url:
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def turbo_stream_redirect_same_page?(url)
|
|
15
|
+
return false if request.referer.blank?
|
|
16
|
+
turbo_stream_redirect_normalize_url(url) == turbo_stream_redirect_normalize_url(request.referer)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def turbo_stream_redirect_normalize_url(url)
|
|
20
|
+
uri = URI.parse(url.to_s)
|
|
21
|
+
path = uri.path.to_s.chomp("/").presence || "/"
|
|
22
|
+
[path, uri.query].compact.join("?")
|
|
23
|
+
rescue URI::InvalidURIError
|
|
24
|
+
url.to_s
|
|
6
25
|
end
|
|
7
26
|
end
|
|
8
27
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Testing
|
|
5
|
+
module AuthHelpers
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
def login_as(account, portal: nil)
|
|
9
|
+
portal ||= current_portal
|
|
10
|
+
if respond_to?(:sign_in_for_tests)
|
|
11
|
+
sign_in_for_tests(account, portal: portal)
|
|
12
|
+
else
|
|
13
|
+
default_rodauth_login(account, portal: portal)
|
|
14
|
+
end
|
|
15
|
+
instance_variable_set(:"@__current_account_#{portal}", account)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sign_out(portal: nil)
|
|
19
|
+
portal ||= current_portal
|
|
20
|
+
post logout_path_for(portal)
|
|
21
|
+
follow_redirect! if response.redirect?
|
|
22
|
+
instance_variable_set(:"@__current_account_#{portal}", nil)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def current_account(portal: nil)
|
|
26
|
+
portal ||= current_portal
|
|
27
|
+
instance_variable_get(:"@__current_account_#{portal}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def with_portal(portal)
|
|
31
|
+
prev = @__portal_override
|
|
32
|
+
@__portal_override = portal
|
|
33
|
+
yield
|
|
34
|
+
ensure
|
|
35
|
+
@__portal_override = prev
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def default_rodauth_login(account, portal:)
|
|
41
|
+
post login_path_for(portal), params: {email: account.email, password: "password123"}
|
|
42
|
+
follow_redirect! if response.redirect?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def login_path_for(portal)
|
|
46
|
+
"/#{account_table_for(portal)}/login"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def logout_path_for(portal)
|
|
50
|
+
"/#{account_table_for(portal)}/logout"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def account_table_for(portal)
|
|
54
|
+
case portal
|
|
55
|
+
when :admin then "admins"
|
|
56
|
+
when :user, :org then "users"
|
|
57
|
+
else portal.to_s.pluralize
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -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"
|
data/lib/plutonium/version.rb
CHANGED