plutonium 0.45.3 → 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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +150 -0
- data/.claude/skills/plutonium-assets/SKILL.md +248 -157
- data/.claude/skills/{plutonium-rodauth → plutonium-auth}/SKILL.md +195 -229
- data/.claude/skills/plutonium-controller/SKILL.md +9 -2
- data/.claude/skills/plutonium-create-resource/SKILL.md +22 -1
- data/.claude/skills/plutonium-definition/SKILL.md +521 -7
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +317 -0
- data/.claude/skills/plutonium-forms/SKILL.md +8 -1
- data/.claude/skills/plutonium-installation/SKILL.md +25 -2
- data/.claude/skills/plutonium-interaction/SKILL.md +32 -2
- data/.claude/skills/plutonium-invites/SKILL.md +11 -7
- data/.claude/skills/plutonium-model/SKILL.md +50 -50
- data/.claude/skills/plutonium-nested-resources/SKILL.md +18 -1
- data/.claude/skills/plutonium-package/SKILL.md +8 -1
- data/.claude/skills/plutonium-policy/SKILL.md +69 -78
- data/.claude/skills/plutonium-portal/SKILL.md +26 -70
- data/.claude/skills/plutonium-testing/SKILL.md +268 -0
- data/.claude/skills/plutonium-views/SKILL.md +9 -2
- data/.yarnrc.yml +1 -0
- data/CHANGELOG.md +38 -0
- data/app/assets/plutonium.css +1 -1
- data/app/views/rodauth/_login_form.html.erb +0 -3
- data/app/views/rodauth/confirm_password.html.erb +0 -4
- data/app/views/rodauth/create_account.html.erb +0 -3
- data/app/views/rodauth/logout.html.erb +0 -3
- 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-08-plutonium-skills-overhaul.md +481 -0
- 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-08-plutonium-skills-overhaul-design.md +236 -0
- data/docs/superpowers/specs/2026-04-14-plutonium-testing-design.md +364 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +8 -0
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +56 -0
- data/lib/generators/pu/invites/install_generator.rb +8 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +43 -0
- data/lib/generators/pu/profile/concerns/profile_arguments.rb +10 -4
- data/lib/generators/pu/profile/conn_generator.rb +9 -12
- data/lib/generators/pu/profile/install_generator.rb +5 -2
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
- data/lib/generators/pu/saas/portal_generator.rb +4 -9
- data/lib/generators/pu/saas/welcome/templates/app/views/welcome/onboarding.html.erb.tt +2 -2
- 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/core/controller.rb +18 -1
- data/lib/plutonium/engine.rb +18 -5
- 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/layout/rodauth_layout.rb +6 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +2 -0
- data/package.json +1 -1
- data/yarn.lock +6037 -3893
- metadata +27 -8
- data/.claude/skills/plutonium/skill.md +0 -130
- data/.claude/skills/plutonium-definition-actions/SKILL.md +0 -424
- data/.claude/skills/plutonium-definition-query/SKILL.md +0 -364
- data/.claude/skills/plutonium-profile/SKILL.md +0 -276
- data/.claude/skills/plutonium-theming/SKILL.md +0 -424
|
@@ -23,9 +23,65 @@ module Pu
|
|
|
23
23
|
|
|
24
24
|
generate "active_shrine:install"
|
|
25
25
|
template "config/initializers/shrine.rb", force: true
|
|
26
|
+
|
|
27
|
+
disable_active_storage_railtie
|
|
28
|
+
include_active_shrine_model_in_application_record
|
|
26
29
|
rescue => e
|
|
27
30
|
exception "#{self.class} failed:", e
|
|
28
31
|
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Active Storage and active_shrine both define `has_one_attached`. Active
|
|
36
|
+
# Storage is loaded by `require "rails/all"`, so it wins by default and
|
|
37
|
+
# `has_one_attached :foo` quietly creates Active Storage attachments
|
|
38
|
+
# (which fail at runtime because the table doesn't exist). Replace
|
|
39
|
+
# `rails/all` with explicit framework requires that exclude
|
|
40
|
+
# active_storage/engine.
|
|
41
|
+
def disable_active_storage_railtie
|
|
42
|
+
return unless File.exist?("config/application.rb")
|
|
43
|
+
unless File.read("config/application.rb").include?(%(require "rails/all"))
|
|
44
|
+
say_status :warn,
|
|
45
|
+
"config/application.rb does not use `require \"rails/all\"`; skipping Active Storage railtie removal. " \
|
|
46
|
+
"Ensure active_storage/engine is NOT required, or `has_one_attached` will resolve to Active Storage instead of active_shrine.",
|
|
47
|
+
:yellow
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
gsub_file "config/application.rb",
|
|
52
|
+
/^require "rails\/all"$/,
|
|
53
|
+
<<~RUBY.strip
|
|
54
|
+
require "rails"
|
|
55
|
+
# Active Storage is intentionally excluded — file uploads use active_shrine.
|
|
56
|
+
%w[
|
|
57
|
+
active_record/railtie
|
|
58
|
+
active_model/railtie
|
|
59
|
+
active_job/railtie
|
|
60
|
+
action_controller/railtie
|
|
61
|
+
action_view/railtie
|
|
62
|
+
action_mailer/railtie
|
|
63
|
+
action_cable/engine
|
|
64
|
+
rails/test_unit/railtie
|
|
65
|
+
].each { |railtie| require railtie }
|
|
66
|
+
RUBY
|
|
67
|
+
|
|
68
|
+
# Strip per-environment active_storage.service config since the railtie
|
|
69
|
+
# is gone.
|
|
70
|
+
Dir.glob("config/environments/*.rb").each do |env_file|
|
|
71
|
+
gsub_file env_file,
|
|
72
|
+
/^\s*config\.active_storage\.service\s*=.*\n/,
|
|
73
|
+
""
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Include ActiveShrine::Model on ApplicationRecord so the gem's
|
|
78
|
+
# `has_one_attached` / `has_many_attached` macros are available everywhere.
|
|
79
|
+
def include_active_shrine_model_in_application_record
|
|
80
|
+
return unless File.exist?("app/models/application_record.rb")
|
|
81
|
+
return if File.read("app/models/application_record.rb").include?("ActiveShrine::Model")
|
|
82
|
+
|
|
83
|
+
inject_into_class "app/models/application_record.rb", "ApplicationRecord", " include ActiveShrine::Model\n"
|
|
84
|
+
end
|
|
29
85
|
end
|
|
30
86
|
end
|
|
31
87
|
end
|
|
@@ -186,7 +186,7 @@ module Pu
|
|
|
186
186
|
def current_membership
|
|
187
187
|
return unless entity_scope && user
|
|
188
188
|
|
|
189
|
-
@current_membership ||= #{membership_model}.find_by(#{entity_association_name}: entity_scope,
|
|
189
|
+
@current_membership ||= #{membership_model}.find_by(#{entity_association_name}: entity_scope, #{user_association_name}: user)
|
|
190
190
|
end
|
|
191
191
|
RUBY
|
|
192
192
|
|
|
@@ -382,6 +382,13 @@ module Pu
|
|
|
382
382
|
PlutoniumGenerators::Generator.derive_association_name(membership_model, entity_model)
|
|
383
383
|
end
|
|
384
384
|
|
|
385
|
+
# Returns the association name for user on the membership model.
|
|
386
|
+
# Same logic as entity_association_name but for the user side.
|
|
387
|
+
# e.g., RestaurantStaffUser -> StaffUser uses :staff_user (not :user)
|
|
388
|
+
def user_association_name
|
|
389
|
+
PlutoniumGenerators::Generator.derive_association_name(membership_model, user_model)
|
|
390
|
+
end
|
|
391
|
+
|
|
385
392
|
def entity_in_package?
|
|
386
393
|
options[:dest] != "main_app"
|
|
387
394
|
end
|
|
@@ -472,6 +472,49 @@ module PlutoniumGenerators
|
|
|
472
472
|
end
|
|
473
473
|
end
|
|
474
474
|
|
|
475
|
+
#
|
|
476
|
+
# Injects helper methods into a Plutonium Concerns::Controller file,
|
|
477
|
+
# merging with any existing `included do` and `private` sections.
|
|
478
|
+
#
|
|
479
|
+
# ActiveSupport::Concern only permits ONE `included do` block per concern,
|
|
480
|
+
# so we cannot naively append a new block when multiple generators need to
|
|
481
|
+
# register helper_methods in the same file.
|
|
482
|
+
#
|
|
483
|
+
# @param file [String] path to the concerns/controller.rb file
|
|
484
|
+
# @param helper_methods [Array<Symbol>] names to expose via `helper_method`
|
|
485
|
+
# @param methods [String] method definitions (already indented to 6 spaces)
|
|
486
|
+
#
|
|
487
|
+
def inject_into_concerns_controller(file, helper_methods:, methods:)
|
|
488
|
+
helper_list = Array(helper_methods).map { |m| ":#{m}" }.join(", ")
|
|
489
|
+
|
|
490
|
+
in_root do
|
|
491
|
+
# 1. helper_method declaration: merge into existing `included do` block,
|
|
492
|
+
# otherwise create a new block right after the `# add concerns above.` marker.
|
|
493
|
+
content = File.read(file)
|
|
494
|
+
if (match = content.match(/^(?<indent>[ \t]*)included do\n/))
|
|
495
|
+
indent = match[:indent]
|
|
496
|
+
inject_into_file file,
|
|
497
|
+
"#{indent} helper_method #{helper_list}\n",
|
|
498
|
+
after: /^[ \t]*included do\n/
|
|
499
|
+
else
|
|
500
|
+
block = "\n included do\n helper_method #{helper_list}\n end\n"
|
|
501
|
+
inject_into_file file, block, after: /# add concerns above\.\n/
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# 2. Method definitions: append into the existing `private` section if any,
|
|
505
|
+
# otherwise create one just before the closing ` end`s of the concern.
|
|
506
|
+
content = File.read(file)
|
|
507
|
+
trimmed_methods = methods.sub(/\A\n+/, "").chomp
|
|
508
|
+
if content.match?(/^[ \t]*private\n/)
|
|
509
|
+
inject_into_file file, "\n#{trimmed_methods}\n", after: /^[ \t]*private\n/
|
|
510
|
+
else
|
|
511
|
+
inject_into_file file,
|
|
512
|
+
"\n private\n\n#{trimmed_methods}\n",
|
|
513
|
+
before: /^ end\n end\nend\s*\z/
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
475
518
|
def file_includes?(path, check)
|
|
476
519
|
destination = File.expand_path(path, destination_root)
|
|
477
520
|
return false unless File.exist?(destination)
|
|
@@ -7,15 +7,21 @@ module Pu
|
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
9
|
included do
|
|
10
|
-
argument :name, type: :string,
|
|
10
|
+
argument :name, type: :string, required: false, banner: "NAME"
|
|
11
11
|
argument :attributes, type: :array, default: [], banner: "field[:type] field[:type]"
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
# Normalize arguments: if name
|
|
14
|
+
# Normalize arguments: if name is omitted, default to "{UserModel}Profile";
|
|
15
|
+
# if name looks like an attribute (contains ":"), treat it as an attribute
|
|
16
|
+
# and still default the profile name to "{UserModel}Profile".
|
|
15
17
|
def normalize_arguments
|
|
16
|
-
|
|
18
|
+
default_name = "#{options[:user_model] || "User"}Profile"
|
|
19
|
+
if name.nil?
|
|
20
|
+
@profile_name = default_name
|
|
21
|
+
@profile_attributes = attributes
|
|
22
|
+
elsif name.include?(":")
|
|
17
23
|
@profile_attributes = [name, *attributes]
|
|
18
|
-
@profile_name =
|
|
24
|
+
@profile_name = default_name
|
|
19
25
|
else
|
|
20
26
|
@profile_name = name
|
|
21
27
|
@profile_attributes = attributes
|
|
@@ -10,7 +10,7 @@ module Pu
|
|
|
10
10
|
|
|
11
11
|
desc "Connect a Profile resource to a portal and configure the profile_url helper"
|
|
12
12
|
|
|
13
|
-
argument :name, type: :string,
|
|
13
|
+
argument :name, type: :string, required: false, banner: "RESOURCE"
|
|
14
14
|
|
|
15
15
|
class_option :dest, type: :string,
|
|
16
16
|
desc: "Destination portal"
|
|
@@ -106,14 +106,7 @@ module Pu
|
|
|
106
106
|
end
|
|
107
107
|
|
|
108
108
|
def add_profile_url_helper
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
included do
|
|
112
|
-
helper_method :profile_url
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
private
|
|
116
|
-
|
|
109
|
+
methods = <<-RUBY
|
|
117
110
|
# Returns the URL to the user's profile page.
|
|
118
111
|
def profile_url
|
|
119
112
|
profile = current_user.#{profile_association}
|
|
@@ -124,15 +117,19 @@ module Pu
|
|
|
124
117
|
end
|
|
125
118
|
end
|
|
126
119
|
RUBY
|
|
127
|
-
|
|
120
|
+
inject_into_concerns_controller concerns_controller_path,
|
|
121
|
+
helper_methods: [:profile_url],
|
|
122
|
+
methods: methods
|
|
128
123
|
end
|
|
129
124
|
|
|
130
125
|
def profile_association
|
|
131
|
-
|
|
126
|
+
# The install generator always exposes the profile as `:profile` on the
|
|
127
|
+
# user model (via class_name:), regardless of the underlying class name.
|
|
128
|
+
"profile"
|
|
132
129
|
end
|
|
133
130
|
|
|
134
131
|
def resource_class_name
|
|
135
|
-
name.camelize
|
|
132
|
+
(name.presence || "#{options[:user_model]}Profile").camelize
|
|
136
133
|
end
|
|
137
134
|
|
|
138
135
|
def user_table
|
|
@@ -37,10 +37,13 @@ module Pu
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def add_user_association
|
|
40
|
+
# Always expose the association as `:profile` on the user model so that
|
|
41
|
+
# `current_user.profile` works regardless of the underlying class name
|
|
42
|
+
# (e.g. UserProfile, StaffUserProfile, AccountSettings).
|
|
40
43
|
association = if dest_package?
|
|
41
|
-
" has_one
|
|
44
|
+
" has_one :profile, class_name: \"#{namespaced_class_name}\", dependent: :destroy\n"
|
|
42
45
|
else
|
|
43
|
-
" has_one
|
|
46
|
+
" has_one :profile, class_name: \"#{class_name}\", dependent: :destroy\n"
|
|
44
47
|
end
|
|
45
48
|
inject_into_file user_model_path, association,
|
|
46
49
|
before: /^\s*# add has_one associations above\.\n/
|
|
@@ -285,6 +285,9 @@ class <%= account_path.classify %>RodauthPlugin < RodauthPlugin
|
|
|
285
285
|
<% end -%>
|
|
286
286
|
<% if verify_account? -%>
|
|
287
287
|
|
|
288
|
+
# Redirect to login page after requesting account verification email.
|
|
289
|
+
verify_account_email_sent_redirect { login_path }
|
|
290
|
+
|
|
288
291
|
# Redirect to wherever login redirects to after account verification.
|
|
289
292
|
verify_account_redirect { login_redirect }
|
|
290
293
|
<% end -%>
|
|
@@ -70,14 +70,7 @@ module Pu
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def add_entity_url_helper
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
included do
|
|
76
|
-
helper_method :entity_url, :user_entities
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
private
|
|
80
|
-
|
|
73
|
+
methods = <<-RUBY
|
|
81
74
|
# Returns the URL to the current entity's show page.
|
|
82
75
|
def entity_url
|
|
83
76
|
resource_url_for(current_scoped_entity)
|
|
@@ -88,7 +81,9 @@ module Pu
|
|
|
88
81
|
@user_entities ||= current_user.#{entity_table.pluralize}
|
|
89
82
|
end
|
|
90
83
|
RUBY
|
|
91
|
-
|
|
84
|
+
inject_into_concerns_controller concerns_controller_path,
|
|
85
|
+
helper_methods: [:entity_url, :user_entities],
|
|
86
|
+
methods: methods
|
|
92
87
|
end
|
|
93
88
|
|
|
94
89
|
def add_entity_link_to_header
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<%% end %>
|
|
24
24
|
<% if profile? -%>
|
|
25
25
|
|
|
26
|
-
<%%=
|
|
26
|
+
<%%= fields_for :profile, @profile do |pf| %>
|
|
27
27
|
<div>
|
|
28
28
|
<%%= pf.label :name, "Your Name", class: "block mb-2 text-sm font-medium text-[var(--pu-text)]" %>
|
|
29
29
|
<%%= pf.text_field :name,
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
<%% end %>
|
|
35
35
|
<% end -%>
|
|
36
36
|
|
|
37
|
-
<%%=
|
|
37
|
+
<%%= fields_for :<%= entity_table %>, @<%= entity_table %> do |ef| %>
|
|
38
38
|
<div>
|
|
39
39
|
<%%= ef.label :name, "Workspace Name", class: "block mb-2 text-sm font-medium text-[var(--pu-text)]" %>
|
|
40
40
|
<%%= ef.text_field :name,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../lib/plutonium_generators"
|
|
4
|
+
|
|
5
|
+
module Pu
|
|
6
|
+
module Test
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
include PlutoniumGenerators::Generator
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
desc "Install Plutonium::Testing scaffolding"
|
|
13
|
+
|
|
14
|
+
def install
|
|
15
|
+
add_require_to_test_helper
|
|
16
|
+
copy_support_file
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def add_require_to_test_helper
|
|
22
|
+
helper = "test/test_helper.rb"
|
|
23
|
+
return unless File.exist?(helper)
|
|
24
|
+
line = %(require "plutonium/testing")
|
|
25
|
+
return if File.read(helper).include?(line)
|
|
26
|
+
append_to_file helper, "\n#{line}\n"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def copy_support_file
|
|
30
|
+
copy_file "plutonium_testing.rb", "test/support/plutonium_testing.rb"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Plutonium::Testing project hooks.
|
|
4
|
+
#
|
|
5
|
+
# Override authentication for non-Rodauth setups by defining a top-level helper
|
|
6
|
+
# that gets included into integration tests:
|
|
7
|
+
#
|
|
8
|
+
# module PlutoniumTestingOverrides
|
|
9
|
+
# def sign_in_for_tests(account, portal:)
|
|
10
|
+
# # your custom auth flow here
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# ActiveSupport::TestCase.include(PlutoniumTestingOverrides)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../lib/plutonium_generators"
|
|
4
|
+
|
|
5
|
+
module Pu
|
|
6
|
+
module Test
|
|
7
|
+
class ScaffoldGenerator < Rails::Generators::NamedBase
|
|
8
|
+
include PlutoniumGenerators::Generator
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
desc "Scaffold Plutonium::Testing tests for a resource across one or more portals"
|
|
13
|
+
|
|
14
|
+
class_option :portals, type: :array, required: true,
|
|
15
|
+
desc: "Portals to scaffold tests for (e.g. admin,org)"
|
|
16
|
+
class_option :concerns, type: :array, default: %w[crud policy definition],
|
|
17
|
+
desc: "Concerns to include (crud,policy,definition,nested,model,interaction)"
|
|
18
|
+
class_option :parent, type: :string, desc: "Parent association for nested resources"
|
|
19
|
+
class_option :dest, type: :string, default: "main_app",
|
|
20
|
+
desc: "main_app or package name"
|
|
21
|
+
|
|
22
|
+
def scaffold
|
|
23
|
+
options[:portals].each { |portal| scaffold_for_portal(portal) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
CONCERN_MAP = {
|
|
29
|
+
"crud" => "ResourceCrud",
|
|
30
|
+
"policy" => "ResourcePolicy",
|
|
31
|
+
"definition" => "ResourceDefinition",
|
|
32
|
+
"model" => "ResourceModel",
|
|
33
|
+
"interaction" => "ResourceInteraction",
|
|
34
|
+
"nested" => "NestedResource",
|
|
35
|
+
"portal_access" => "PortalAccess"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
def concern_module_name(concern)
|
|
39
|
+
CONCERN_MAP.fetch(concern) { concern.camelize }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def scaffold_for_portal(portal)
|
|
43
|
+
@portal = portal
|
|
44
|
+
@resource_class = name
|
|
45
|
+
@file_name = name.underscore.tr("/", "_")
|
|
46
|
+
@class_name = "#{portal.camelize}Portal::#{name.tr("::", "")}Test"
|
|
47
|
+
@concerns = options[:concerns]
|
|
48
|
+
@parent = options[:parent]
|
|
49
|
+
target_dir = (options[:dest] == "main_app") ? "test/integration" : "packages/#{options[:dest]}/test/integration"
|
|
50
|
+
target = "#{target_dir}/#{portal}_portal/#{@file_name}_test.rb"
|
|
51
|
+
template "integration_test.rb.tt", target
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class <%= @class_name %> < ActionDispatch::IntegrationTest
|
|
6
|
+
<% @concerns.each do |c| -%>
|
|
7
|
+
include Plutonium::Testing::<%= concern_module_name(c) %>
|
|
8
|
+
<% end -%>
|
|
9
|
+
|
|
10
|
+
resource_tests_for <%= @resource_class %>,
|
|
11
|
+
portal: :<%= @portal %><% if @parent %>,
|
|
12
|
+
parent: :<%= @parent %><% end %>
|
|
13
|
+
|
|
14
|
+
setup do
|
|
15
|
+
# TODO: replace with your factories.
|
|
16
|
+
@account = nil
|
|
17
|
+
login_as(@account)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
<% if @concerns.include?("crud") -%>
|
|
21
|
+
def create_resource!
|
|
22
|
+
<%= @resource_class %>.create!(
|
|
23
|
+
# TODO: fill in valid attributes
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def valid_create_params
|
|
28
|
+
{} # TODO
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def valid_update_params
|
|
32
|
+
{} # TODO
|
|
33
|
+
end
|
|
34
|
+
<% end -%>
|
|
35
|
+
<% if @concerns.include?("policy") -%>
|
|
36
|
+
|
|
37
|
+
def policy_roles
|
|
38
|
+
{<%= @portal %>: -> { @account }}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def policy_record
|
|
42
|
+
create_resource!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def policy_matrix
|
|
46
|
+
{
|
|
47
|
+
index: %i[<%= @portal %>],
|
|
48
|
+
show: %i[<%= @portal %>],
|
|
49
|
+
create: %i[<%= @portal %>],
|
|
50
|
+
update: %i[<%= @portal %>],
|
|
51
|
+
destroy: %i[<%= @portal %>]
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
<% end -%>
|
|
55
|
+
<% if @concerns.include?("nested") && @parent -%>
|
|
56
|
+
|
|
57
|
+
def parent_record!
|
|
58
|
+
# TODO: return the current tenant for this test
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def other_parent_record!
|
|
62
|
+
# TODO: return a sibling tenant
|
|
63
|
+
end
|
|
64
|
+
<% end -%>
|
|
65
|
+
end
|
|
@@ -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
|
data/lib/plutonium/engine.rb
CHANGED
|
@@ -5,17 +5,30 @@ module Plutonium
|
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
class_methods do
|
|
8
|
-
attr_reader :
|
|
8
|
+
attr_reader :scoped_entity_strategy, :scoped_entity_param_key, :scoped_entity_route_key
|
|
9
9
|
|
|
10
|
+
# Store the entity class *by name* and resolve it lazily on every call.
|
|
11
|
+
# Capturing the class object directly causes stale references after Rails
|
|
12
|
+
# autoreload: the constant is reloaded but @scoped_entity_class still
|
|
13
|
+
# points at the previous (now-orphaned) class object, which then fails
|
|
14
|
+
# type checks against freshly reloaded instances.
|
|
10
15
|
def scope_to_entity(entity_class, strategy: :path, param_key: nil, route_key: nil)
|
|
11
|
-
@
|
|
16
|
+
@scoped_entity_class_name = entity_class.is_a?(Class) ? entity_class.name : entity_class.to_s
|
|
12
17
|
@scoped_entity_strategy = strategy
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
# param_key / route_key are derived from the class once at declaration
|
|
19
|
+
# time — they're stable strings and don't depend on the live class
|
|
20
|
+
# identity, so caching them is safe.
|
|
21
|
+
resolved = @scoped_entity_class_name.constantize
|
|
22
|
+
@scoped_entity_param_key = param_key || :"#{resolved.model_name.singular_route_key}_scope"
|
|
23
|
+
@scoped_entity_route_key = route_key || resolved.model_name.singular.to_sym
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def scoped_entity_class
|
|
27
|
+
@scoped_entity_class_name&.constantize
|
|
15
28
|
end
|
|
16
29
|
|
|
17
30
|
def scoped_to_entity?
|
|
18
|
-
|
|
31
|
+
@scoped_entity_class_name.present?
|
|
19
32
|
end
|
|
20
33
|
|
|
21
34
|
def dom_id
|
|
@@ -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
|