admin_suite 0.4.0 → 0.6.1
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/.gitignore +7 -0
- data/CHANGELOG.md +203 -18
- data/CONTRIBUTING.md +9 -4
- data/README.md +21 -5
- data/app/assets/vendor/chart.umd.min.js +14 -0
- data/app/controllers/admin_suite/application_controller.rb +12 -11
- data/app/controllers/admin_suite/mcp_controller.rb +36 -0
- data/app/controllers/admin_suite/resources_controller.rb +71 -17
- data/app/helpers/admin_suite/base_helper.rb +187 -10
- data/app/javascript/admin_suite_application.js +3 -0
- data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
- data/app/views/admin_suite/panels/_chart.html.erb +158 -18
- data/app/views/admin_suite/resources/index.html.erb +25 -5
- data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
- data/app/views/layouts/admin_suite/application.html.erb +6 -0
- data/config/routes.rb +8 -0
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +46 -32
- data/lib/admin_suite/auth/host_user.rb +42 -0
- data/lib/admin_suite/auth/strategy.rb +1 -1
- data/lib/admin_suite/auth.rb +15 -0
- data/lib/admin_suite/authorization_context.rb +28 -0
- data/lib/admin_suite/configuration.rb +66 -8
- data/lib/admin_suite/engine.rb +1 -1
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
- data/lib/admin_suite/mcp/authorization.rb +68 -0
- data/lib/admin_suite/mcp/serializer.rb +138 -0
- data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
- data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
- data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
- data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
- data/lib/admin_suite/mcp.rb +53 -0
- data/lib/admin_suite/query.rb +71 -0
- data/lib/admin_suite/renderer_registry.rb +11 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +23 -7
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
- data/test/controllers/resources_controller_test.rb +19 -13
- data/test/integration/association_linking_test.rb +292 -0
- data/test/integration/authentication_test.rb +10 -0
- data/test/integration/authorization_test.rb +20 -3
- data/test/integration/chart_panel_test.rb +491 -0
- data/test/integration/dashboard_test.rb +9 -2
- data/test/integration/index_query_characterization_test.rb +229 -0
- data/test/integration/index_table_test.rb +289 -0
- data/test/integration/mcp_aggregate_test.rb +44 -0
- data/test/integration/mcp_authorization_test.rb +102 -0
- data/test/integration/mcp_endpoint_test.rb +89 -0
- data/test/integration/mcp_get_record_test.rb +62 -0
- data/test/integration/mcp_instrumentation_test.rb +107 -0
- data/test/integration/mcp_list_records_test.rb +75 -0
- data/test/integration/mcp_parity_test.rb +155 -0
- data/test/integration/mcp_release_test.rb +131 -0
- data/test/integration/navigation_sections_test.rb +21 -0
- data/test/integration/read_only_resource_test.rb +128 -2
- data/test/integration/searchable_select_search_test.rb +369 -0
- data/test/integration/show_hide_blank_test.rb +195 -0
- data/test/integration/toggle_test.rb +106 -0
- data/test/lib/auth_host_user_test.rb +52 -0
- data/test/lib/auth_http_basic_test.rb +10 -0
- data/test/lib/auth_test.rb +20 -3
- data/test/lib/authorization_context_test.rb +127 -0
- data/test/lib/definition_loader_test.rb +12 -0
- data/test/lib/engine_defaults_test.rb +1 -2
- data/test/lib/form_field_renderer_test.rb +83 -11
- data/test/lib/format_table_cell_test.rb +49 -0
- data/test/lib/index_includes_test.rb +223 -0
- data/test/lib/mcp_serializer_test.rb +107 -0
- data/test/lib/query_test.rb +91 -0
- data/test/lib/removed_deprecations_test.rb +16 -0
- data/test/lib/renderer_test.rb +23 -7
- data/test/publish_workflow_test.rb +63 -0
- data/test/test_helper.rb +73 -8
- metadata +76 -9
- data/lib/admin_suite/renderers/legacy_gleania.rb +0 -230
- data/test/lib/legacy_renderer_deprecation_test.rb +0 -35
- data/test/lib/resource_exportable_deprecation_test.rb +0 -39
|
@@ -6,31 +6,52 @@ module AdminSuite
|
|
|
6
6
|
class FormFieldRendererTest < ActionView::TestCase
|
|
7
7
|
include AdminSuite::BaseHelper
|
|
8
8
|
|
|
9
|
-
Record = Struct.new(:name, :body, :enabled) do
|
|
9
|
+
Record = Struct.new(:name, :body, :enabled, :error_messages) do
|
|
10
|
+
# Both a class-level and an instance-level `model_name` are needed:
|
|
11
|
+
# `render_toggle_field`/`render_searchable_select`/`render_multi_select`
|
|
12
|
+
# all call `resource.class.model_name.param_key` (class-level), while
|
|
13
|
+
# `form_with` itself wants the instance-level accessor. (`extend
|
|
14
|
+
# ActiveModel::Naming` would give both via its own delegation, but
|
|
15
|
+
# then redefining `model_name` below to force the short "Record" name
|
|
16
|
+
# -- rather than the fully-qualified nested constant name -- clashes
|
|
17
|
+
# with that delegation and triggers a "method redefined" warning.
|
|
18
|
+
# Defining both explicitly avoids it.) The pre-existing instance-level
|
|
19
|
+
# `model_name` never covered the class-level call site because no test
|
|
20
|
+
# here exercised `:toggle`/`:searchable_select`/`:multi_select` before.
|
|
21
|
+
def self.model_name
|
|
22
|
+
@model_name ||= ActiveModel::Name.new(self, nil, "Record")
|
|
23
|
+
end
|
|
24
|
+
|
|
10
25
|
def to_model = self
|
|
11
|
-
def model_name =
|
|
26
|
+
def model_name = self.class.model_name
|
|
12
27
|
def persisted? = false
|
|
13
28
|
def to_key = nil
|
|
14
29
|
|
|
15
30
|
# render_form_field unconditionally calls `resource.errors[field.name]`
|
|
16
31
|
# for every field type (to add the error border class / message), so
|
|
17
32
|
# the double needs an `errors` object supporting `[]` -> Array-like
|
|
18
|
-
# (`.any?`, `.first`).
|
|
19
|
-
# error, so a Hash defaulting to `[]` is sufficient
|
|
20
|
-
|
|
33
|
+
# (`.any?`, `.first`). Most tests here don't exercise an actual
|
|
34
|
+
# validation error, so a Hash defaulting to `[]` is sufficient; tests
|
|
35
|
+
# that do pass `error_messages` (a plain Hash of field name -> Array of
|
|
36
|
+
# messages) to get real per-field errors without losing the default.
|
|
37
|
+
def errors
|
|
38
|
+
Hash.new([]).merge(error_messages || {})
|
|
39
|
+
end
|
|
21
40
|
end
|
|
22
41
|
|
|
23
|
-
def field(name, type)
|
|
42
|
+
def field(name, type, **overrides)
|
|
24
43
|
Admin::Base::Resource::FieldDefinition.new(
|
|
25
|
-
|
|
26
|
-
|
|
44
|
+
{
|
|
45
|
+
name: name, type: type, required: false, label: name.to_s.humanize,
|
|
46
|
+
readonly: false, multiple: false, creatable: false, preview: true
|
|
47
|
+
}.merge(overrides)
|
|
27
48
|
)
|
|
28
49
|
end
|
|
29
50
|
|
|
30
|
-
def render_field(type, name: :name)
|
|
31
|
-
record = Record.new("x", "y", true)
|
|
51
|
+
def render_field(type, name: :name, error_messages: nil, **field_overrides)
|
|
52
|
+
record = Record.new("x", "y", true, error_messages)
|
|
32
53
|
html = nil
|
|
33
|
-
form_with(model: record, url: "/", scope: :record) { |f| html = render_form_field(f, field(name, type), record) }
|
|
54
|
+
form_with(model: record, url: "/", scope: :record) { |f| html = render_form_field(f, field(name, type, **field_overrides), record) }
|
|
34
55
|
html.to_s
|
|
35
56
|
end
|
|
36
57
|
|
|
@@ -60,5 +81,56 @@ module AdminSuite
|
|
|
60
81
|
test "the label is rendered for every field" do
|
|
61
82
|
assert_includes render_field(:text), "Name"
|
|
62
83
|
end
|
|
84
|
+
|
|
85
|
+
test "a field with a validation error renders the red border and error message" do
|
|
86
|
+
html = render_field(:text, error_messages: { name: [ "is invalid" ] })
|
|
87
|
+
assert_includes html, "border-red-500"
|
|
88
|
+
assert_includes html, "is invalid"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The full set of field types `FieldRendererRegistry` is expected to
|
|
92
|
+
# have registered (see `lib/admin_suite/ui/field_renderer_registry.rb`).
|
|
93
|
+
# Pinned explicitly here, NOT derived from the registry itself: an
|
|
94
|
+
# earlier version of this test iterated `handlers.keys` directly, which
|
|
95
|
+
# cannot detect a deletion -- a deleted key simply stops appearing in
|
|
96
|
+
# the iteration, so the loop body never runs for it and the test still
|
|
97
|
+
# passes with one fewer assertion (verified by deleting the `:toggle`
|
|
98
|
+
# registration: 42 assertions became 41, 0 failures). The
|
|
99
|
+
# size-and-membership assertion in the test below is what actually
|
|
100
|
+
# catches that; a self-referential "iterate the thing you're testing"
|
|
101
|
+
# loop cannot.
|
|
102
|
+
EXPECTED_FIELD_TYPES = %i[
|
|
103
|
+
textarea url email number toggle label select searchable_select
|
|
104
|
+
dependent_select multi_select tags image attachment trix rich_text
|
|
105
|
+
markdown file datetime date time json code text string
|
|
106
|
+
].freeze
|
|
107
|
+
|
|
108
|
+
# :trix/:rich_text both call `f.rich_text_area`, which only exists once
|
|
109
|
+
# ActionText's FormBuilder extension is loaded -- and per the plan's
|
|
110
|
+
# Test-harness fact #2, this dummy app is deliberately database-free and
|
|
111
|
+
# never requires `action_text/engine`. Confirmed by running the
|
|
112
|
+
# rendering test with them included: `NoMethodError: undefined method
|
|
113
|
+
# 'rich_text_area'`, not a bug in FieldRendererRegistry. There is no way
|
|
114
|
+
# to pin these two types in this harness without pulling in ActionText
|
|
115
|
+
# (and, transitively, ActiveRecord) — out of scope here. Named
|
|
116
|
+
# explicitly (not an implicit skip) so `EXPECTED_FIELD_TYPES.size` and
|
|
117
|
+
# the number of types actually exercised below both stay visible and
|
|
118
|
+
# add up: 24 expected, 2 excluded, 22 rendered.
|
|
119
|
+
UNTESTABLE_WITHOUT_ACTION_TEXT = %i[trix rich_text].freeze
|
|
120
|
+
|
|
121
|
+
test "the registry has exactly the expected set of field types, no more, no fewer" do
|
|
122
|
+
assert_equal EXPECTED_FIELD_TYPES.sort, AdminSuite::UI::FieldRendererRegistry.handlers.keys.sort
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
test "every expected field type except the ActionText-only ones renders non-empty markup" do
|
|
126
|
+
sample_collection = [ [ "One", 1 ], [ "Two", 2 ] ]
|
|
127
|
+
needs_collection = %i[select searchable_select dependent_select multi_select tags]
|
|
128
|
+
|
|
129
|
+
(EXPECTED_FIELD_TYPES - UNTESTABLE_WITHOUT_ACTION_TEXT).each do |type|
|
|
130
|
+
overrides = needs_collection.include?(type) ? { collection: sample_collection } : {}
|
|
131
|
+
html = render_field(type, **overrides)
|
|
132
|
+
assert html.present?, "expected #{type.inspect} to render non-empty markup"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
63
135
|
end
|
|
64
136
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
module AdminSuite
|
|
6
|
+
# `format_table_cell` had no direct unit test anywhere in the suite before
|
|
7
|
+
# this file -- it was only exercised indirectly through
|
|
8
|
+
# `association_linking_test.rb`'s AR-focused fixtures. Covers the branches
|
|
9
|
+
# not already pinned there, and specifically the Integer/Float/BigDecimal
|
|
10
|
+
# delimiting added to match `format_show_value` (see
|
|
11
|
+
# `ShowValueFormatterTest#"integers and floats render delimited"`).
|
|
12
|
+
class FormatTableCellTest < ActionView::TestCase
|
|
13
|
+
include AdminSuite::BaseHelper
|
|
14
|
+
|
|
15
|
+
test "nil renders the em dash placeholder" do
|
|
16
|
+
assert_equal "—", format_table_cell(nil)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
test "booleans render Yes/No" do
|
|
20
|
+
assert_equal "Yes", format_table_cell(true)
|
|
21
|
+
assert_equal "No", format_table_cell(false)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
test "Time and DateTime render a short date-time" do
|
|
25
|
+
assert_equal "Jan 02, 03:04", format_table_cell(Time.utc(2026, 1, 2, 3, 4))
|
|
26
|
+
assert_equal "Jan 02, 03:04", format_table_cell(DateTime.new(2026, 1, 2, 3, 4))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
test "Date renders a short date" do
|
|
30
|
+
assert_equal "Jan 02, 2026", format_table_cell(Date.new(2026, 1, 2))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
test "integers and floats render delimited, matching format_show_value" do
|
|
34
|
+
assert_equal "1,234,567", format_table_cell(1_234_567)
|
|
35
|
+
assert_equal "1,234.5", format_table_cell(1234.5)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
test "BigDecimal renders delimited rather than in exponential notation" do
|
|
39
|
+
result = format_table_cell(BigDecimal("9876.5"))
|
|
40
|
+
assert_includes result, "9,876"
|
|
41
|
+
refute_includes result, "0.98765e4"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
test "plain strings are truncated at 50 characters" do
|
|
45
|
+
assert_equal "hello", format_table_cell("hello")
|
|
46
|
+
assert_equal ("x" * 47) + "...", format_table_cell("x" * 60)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Fixtures for the `includes:` DSL option (Task 4, phase 2b). A scope that
|
|
6
|
+
# genuinely supports `#includes` and records/validates the call, so the
|
|
7
|
+
# controller's "apply when the scope responds" and "degrade when
|
|
8
|
+
# `.includes` itself raises" paths are both exercisable end-to-end. The
|
|
9
|
+
# `LinkingFixtures::Deal`/`ReadOnlyResourceFixtures::Relation` pair (no
|
|
10
|
+
# `#includes` at all) already covers the "skip silently" path -- reused
|
|
11
|
+
# below rather than duplicated.
|
|
12
|
+
module IndexIncludesFixtures
|
|
13
|
+
class IncludesCapableRelation
|
|
14
|
+
include Enumerable
|
|
15
|
+
|
|
16
|
+
KNOWN_ASSOCIATIONS = %i[company owner].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :includes_calls
|
|
19
|
+
|
|
20
|
+
def initialize(records)
|
|
21
|
+
@records = records
|
|
22
|
+
@includes_calls = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each(&block) = @records.each(&block)
|
|
26
|
+
def count(*) = @records.count
|
|
27
|
+
def offset(*) = self
|
|
28
|
+
def limit(*) = self
|
|
29
|
+
|
|
30
|
+
# Mirrors real ActiveRecord's eventual `ActiveRecord::AssociationNotFoundError`
|
|
31
|
+
# for an unknown association name -- raised here at call time (rather
|
|
32
|
+
# than lazily, as real AR does) so the controller's rescue path around
|
|
33
|
+
# the `.includes` call site is directly exercisable.
|
|
34
|
+
def includes(*associations)
|
|
35
|
+
flat = associations.flatten
|
|
36
|
+
invalid = flat.reject { |a| KNOWN_ASSOCIATIONS.include?(a.respond_to?(:to_sym) ? a.to_sym : a) }
|
|
37
|
+
raise ArgumentError, "Association named #{invalid.first.inspect} was not found" if invalid.any?
|
|
38
|
+
|
|
39
|
+
@includes_calls << flat
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class Widget
|
|
45
|
+
extend ActiveModel::Naming
|
|
46
|
+
|
|
47
|
+
attr_reader :id, :name
|
|
48
|
+
|
|
49
|
+
def initialize(id:, name:)
|
|
50
|
+
@id = id
|
|
51
|
+
@name = name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
RELATION = IncludesCapableRelation.new([ new(id: 1, name: "Widget One") ])
|
|
55
|
+
|
|
56
|
+
def self.all = RELATION
|
|
57
|
+
def self.column_names = %w[id name]
|
|
58
|
+
def self.primary_key = "id"
|
|
59
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
60
|
+
def self.find(id) = all.find { |w| w.to_param == id.to_s }
|
|
61
|
+
def to_param = id.to_s
|
|
62
|
+
def attributes = { "id" => id, "name" => name }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class BadWidget
|
|
66
|
+
extend ActiveModel::Naming
|
|
67
|
+
|
|
68
|
+
attr_reader :id, :name
|
|
69
|
+
|
|
70
|
+
def initialize(id:, name:)
|
|
71
|
+
@id = id
|
|
72
|
+
@name = name
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
RELATION = IncludesCapableRelation.new([ new(id: 1, name: "Bad Widget") ])
|
|
76
|
+
|
|
77
|
+
def self.all = RELATION
|
|
78
|
+
def self.column_names = %w[id name]
|
|
79
|
+
def self.primary_key = "id"
|
|
80
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
81
|
+
def self.find(id) = all.find { |w| w.to_param == id.to_s }
|
|
82
|
+
def to_param = id.to_s
|
|
83
|
+
def attributes = { "id" => id, "name" => name }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Self-contained (this file is runnable standalone via
|
|
87
|
+
# `rake test TEST=test/lib/index_includes_test.rb`, so it can't reach
|
|
88
|
+
# into fixtures defined only in association_linking_test.rb): a scope
|
|
89
|
+
# with no `#includes` method at all, mirroring
|
|
90
|
+
# `ReadOnlyResourceFixtures::Relation`, backing a resource that
|
|
91
|
+
# nonetheless declares `includes :company`. Exercises the "skip
|
|
92
|
+
# silently, don't raise" path.
|
|
93
|
+
class NoIncludesWidget
|
|
94
|
+
extend ActiveModel::Naming
|
|
95
|
+
|
|
96
|
+
attr_reader :id, :name
|
|
97
|
+
|
|
98
|
+
def initialize(id:, name:)
|
|
99
|
+
@id = id
|
|
100
|
+
@name = name
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.all = ReadOnlyResourceFixtures::Relation.new([ new(id: 1, name: "Plain Widget") ])
|
|
104
|
+
def self.column_names = %w[id name]
|
|
105
|
+
def self.primary_key = "id"
|
|
106
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
107
|
+
def self.find(id) = all.find { |w| w.to_param == id.to_s }
|
|
108
|
+
def to_param = id.to_s
|
|
109
|
+
def attributes = { "id" => id, "name" => name }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
module Admin
|
|
114
|
+
module Resources
|
|
115
|
+
class IndexIncludesWidgetResource < Admin::Base::Resource
|
|
116
|
+
model IndexIncludesFixtures::Widget
|
|
117
|
+
portal :ops
|
|
118
|
+
section :observability
|
|
119
|
+
|
|
120
|
+
index do
|
|
121
|
+
columns { column :name }
|
|
122
|
+
includes :company, :owner
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
class IndexIncludesBadWidgetResource < Admin::Base::Resource
|
|
127
|
+
model IndexIncludesFixtures::BadWidget
|
|
128
|
+
portal :ops
|
|
129
|
+
section :observability
|
|
130
|
+
|
|
131
|
+
index do
|
|
132
|
+
columns { column :name }
|
|
133
|
+
includes :nonexistent_association
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
class IndexIncludesNoIncludesWidgetResource < Admin::Base::Resource
|
|
138
|
+
model IndexIncludesFixtures::NoIncludesWidget
|
|
139
|
+
portal :ops
|
|
140
|
+
section :observability
|
|
141
|
+
|
|
142
|
+
index do
|
|
143
|
+
columns { column :name }
|
|
144
|
+
includes :company
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
module AdminSuite
|
|
151
|
+
class IndexIncludesTest < ActionDispatch::IntegrationTest
|
|
152
|
+
test "IndexConfig#includes stores the list of associations" do
|
|
153
|
+
config = Admin::Base::Resource::IndexConfig.new
|
|
154
|
+
config.includes(:company, :owner)
|
|
155
|
+
assert_equal [ :company, :owner ], config.includes_list
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
test "IndexConfig#includes defaults to an empty list" do
|
|
159
|
+
assert_equal [], Admin::Base::Resource::IndexConfig.new.includes_list
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
test "IndexConfig#includes drops a nil association instead of storing garbage" do
|
|
163
|
+
config = Admin::Base::Resource::IndexConfig.new
|
|
164
|
+
config.includes(nil)
|
|
165
|
+
assert_equal [], config.includes_list
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
test "IndexConfig#includes tolerates a String association name" do
|
|
169
|
+
config = Admin::Base::Resource::IndexConfig.new
|
|
170
|
+
config.includes("company")
|
|
171
|
+
assert_equal [ "company" ], config.includes_list
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
test "the DSL exposes includes: on a resource's index_config" do
|
|
175
|
+
assert_equal [ :company, :owner ], Admin::Resources::IndexIncludesWidgetResource.index_config.includes_list
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
test "the controller applies includes: to a scope that responds to includes" do
|
|
179
|
+
IndexIncludesFixtures::Widget::RELATION.includes_calls.clear
|
|
180
|
+
|
|
181
|
+
get "/internal/admin_suite/ops/index_includes_widgets"
|
|
182
|
+
assert_response :success
|
|
183
|
+
|
|
184
|
+
assert_equal [ [ :company, :owner ] ], IndexIncludesFixtures::Widget::RELATION.includes_calls
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
test "a scope whose includes raises for a bad association degrades instead of 500ing" do
|
|
188
|
+
logged = []
|
|
189
|
+
Rails.logger.stub(:warn, ->(msg) { logged << msg }) do
|
|
190
|
+
get "/internal/admin_suite/ops/index_includes_bad_widgets"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
assert_response :success
|
|
194
|
+
assert_includes response.body, "Bad Widget"
|
|
195
|
+
# The rescue in `apply_index_includes` logs the failure -- pinning
|
|
196
|
+
# that here so the "skipped silently" test below (which asserts NO
|
|
197
|
+
# warn fires) is actually distinguishing two different code paths,
|
|
198
|
+
# not just two tests that both happen to pass.
|
|
199
|
+
assert_equal 1, logged.size
|
|
200
|
+
assert_includes logged.first, "nonexistent_association"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
test "a scope that does not respond to includes is skipped silently, not raised" do
|
|
204
|
+
refute IndexIncludesFixtures::NoIncludesWidget.all.respond_to?(:includes)
|
|
205
|
+
|
|
206
|
+
logged = []
|
|
207
|
+
Rails.logger.stub(:warn, ->(msg) { logged << msg }) do
|
|
208
|
+
get "/internal/admin_suite/ops/index_includes_no_includes_widgets"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
assert_response :success
|
|
212
|
+
assert_includes response.body, "Plain Widget"
|
|
213
|
+
# `apply_index_includes`'s `respond_to?(:includes)` guard exists so a
|
|
214
|
+
# scope that can never support includes doesn't spam a warning on
|
|
215
|
+
# every request. Deleting that guard would still return a 200 here
|
|
216
|
+
# (the catch-all rescue produces an identical response), so the
|
|
217
|
+
# response alone doesn't prove the guard does anything -- this
|
|
218
|
+
# asserts its actual purpose: no warn fires on this path. Contrast
|
|
219
|
+
# with the bad-association test above, where one does.
|
|
220
|
+
assert_empty logged
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class McpSerializerTest < ActiveSupport::TestCase
|
|
6
|
+
Column = Data.define(:name)
|
|
7
|
+
Index = Data.define(:columns_list)
|
|
8
|
+
Config = Data.define(:index_config)
|
|
9
|
+
|
|
10
|
+
test "emits only the resource's declared columns" do
|
|
11
|
+
record = Struct.new(:id, :name, :secret_token).new(1, "Widget", "sh-hh")
|
|
12
|
+
config = Config.new(Index.new([Column.new(:id), Column.new(:name)]))
|
|
13
|
+
|
|
14
|
+
row = AdminSuite::Mcp::Serializer.index_row(record, config)
|
|
15
|
+
|
|
16
|
+
assert_equal %w[id name], row.keys.map(&:to_s).sort
|
|
17
|
+
refute_includes row.keys.map(&:to_s), "secret_token"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
test "a raising accessor degrades that cell, not the row" do
|
|
21
|
+
record = Object.new
|
|
22
|
+
def record.id = 1
|
|
23
|
+
def record.name = raise("boom")
|
|
24
|
+
config = Config.new(Index.new([Column.new(:id), Column.new(:name)]))
|
|
25
|
+
|
|
26
|
+
row = AdminSuite::Mcp::Serializer.index_row(record, config)
|
|
27
|
+
|
|
28
|
+
assert_equal 1, row[:id]
|
|
29
|
+
assert_nil row[:name]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
test "association panels return bounded rows containing only declared columns" do
|
|
33
|
+
show = Admin::Base::Resource::ShowConfig.new
|
|
34
|
+
show.panel :children, association: :children, columns: [:name], limit: 500
|
|
35
|
+
config = Struct.new(:show_config).new(show)
|
|
36
|
+
child = Struct.new(:name, :secret_token).new("Visible", "private")
|
|
37
|
+
record = Struct.new(:children).new(Array.new(150, child))
|
|
38
|
+
|
|
39
|
+
payload = AdminSuite::Mcp::Serializer.associations_payload(record, config, max_rows: 100)
|
|
40
|
+
assert_equal 100, payload.fetch(:children).fetch(:rows).size
|
|
41
|
+
assert_equal({ name: "Visible" }, payload.fetch(:children).fetch(:rows).first)
|
|
42
|
+
assert_equal 100, payload.fetch(:children).fetch(:applied_limit)
|
|
43
|
+
refute_includes JSON.generate(payload), "private"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
test "association panels without declared columns do not serialize model attributes" do
|
|
47
|
+
show = Admin::Base::Resource::ShowConfig.new
|
|
48
|
+
show.panel :children, association: :children
|
|
49
|
+
config = Struct.new(:show_config).new(show)
|
|
50
|
+
record = Object.new
|
|
51
|
+
def record.children = raise("must not load an undeclared field set")
|
|
52
|
+
|
|
53
|
+
assert_empty AdminSuite::Mcp::Serializer.associations_payload(record, config, max_rows: 100)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
test "a content lambda serializes the proc result, not a missing attribute" do
|
|
57
|
+
record = Struct.new(:id).new(1)
|
|
58
|
+
column = Admin::Base::Resource::ColumnDefinition.new(name: :listings_count, content: ->(_r) { 42 })
|
|
59
|
+
config = Config.new(Index.new([column]))
|
|
60
|
+
|
|
61
|
+
assert_equal 42, AdminSuite::Mcp::Serializer.index_row(record, config)[:listings_count]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
test "a label lambda serializes the proc result" do
|
|
65
|
+
record = Struct.new(:id).new(1)
|
|
66
|
+
column = Admin::Base::Resource::ColumnDefinition.new(name: :status, content: ->(_r) { "active" }, type: :label)
|
|
67
|
+
config = Config.new(Index.new([column]))
|
|
68
|
+
|
|
69
|
+
assert_equal "active", AdminSuite::Mcp::Serializer.index_row(record, config)[:status]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
test "declared times and associations serialize as parseable primitives, not heap dumps" do
|
|
73
|
+
created_at = Time.utc(2026, 9, 10, 12, 0, 0)
|
|
74
|
+
application = SerializerApplication.new(id: 7, name: "Acme")
|
|
75
|
+
record = Struct.new(:created_at, :application, :blob).new(created_at, application, SerializerOpaque.new)
|
|
76
|
+
config = Config.new(Index.new([Column.new(:created_at), Column.new(:application), Column.new(:blob)]))
|
|
77
|
+
|
|
78
|
+
row = AdminSuite::Mcp::Serializer.index_row(record, config)
|
|
79
|
+
text = AdminSuite::Mcp::Serializer.dump(row)
|
|
80
|
+
|
|
81
|
+
assert_equal "2026-09-10T12:00:00Z", row[:created_at]
|
|
82
|
+
assert_equal created_at, Time.iso8601(row[:created_at])
|
|
83
|
+
assert_equal "Acme", row[:application]
|
|
84
|
+
refute_match(/#<|0x[0-9a-f]+/i, text)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
test "a missing association panel degrades that panel instead of raising" do
|
|
88
|
+
show = Admin::Base::Resource::ShowConfig.new
|
|
89
|
+
show.panel :children, association: :missing_kids, columns: [:name]
|
|
90
|
+
config = Struct.new(:show_config).new(show)
|
|
91
|
+
|
|
92
|
+
payload = AdminSuite::Mcp::Serializer.associations_payload(Object.new, config, max_rows: 100)
|
|
93
|
+
|
|
94
|
+
assert_equal [], payload.fetch(:children).fetch(:rows)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class SerializerApplication < ActiveRecord::Base
|
|
98
|
+
attr_reader :id, :name
|
|
99
|
+
|
|
100
|
+
def initialize(id:, name:)
|
|
101
|
+
@id = id
|
|
102
|
+
@name = name
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class SerializerOpaque; end
|
|
107
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class QueryTest < ActiveSupport::TestCase
|
|
6
|
+
Config = Struct.new(:model_class, :index_config)
|
|
7
|
+
IndexConfig = Struct.new(
|
|
8
|
+
:per_page,
|
|
9
|
+
:includes_list,
|
|
10
|
+
:searchable_fields,
|
|
11
|
+
:filters_list,
|
|
12
|
+
:default_sort,
|
|
13
|
+
:sortable_fields,
|
|
14
|
+
:default_sort_direction
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
class RaisingIncludesRelation < ReadOnlyResourceFixtures::Relation
|
|
18
|
+
def includes(*)
|
|
19
|
+
raise ArgumentError, "bad association"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Model
|
|
24
|
+
class << self
|
|
25
|
+
attr_accessor :relation
|
|
26
|
+
|
|
27
|
+
def all = relation
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
test "per_page falls back to the DSL value when no param is given" do
|
|
32
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 30), params: {})
|
|
33
|
+
|
|
34
|
+
assert_equal 30, query.per_page
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
test "per_page honours the param" do
|
|
38
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 30), params: { per_page: "50" })
|
|
39
|
+
|
|
40
|
+
assert_equal 50, query.per_page
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
test "per_page clamps to MAX_PAGE_SIZE" do
|
|
44
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 30), params: { per_page: "999999" })
|
|
45
|
+
|
|
46
|
+
assert_equal AdminSuite::Query::MAX_PAGE_SIZE, query.per_page
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
test "per_page falls back for junk and non-positive values" do
|
|
50
|
+
["abc", "0", "-5", nil, "", [], true].each do |value|
|
|
51
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 30), params: { per_page: value })
|
|
52
|
+
|
|
53
|
+
assert_equal 30, query.per_page, "per_page: #{value.inspect} must fall back to the DSL value"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
test "the cap applies to DSL defaults as well as explicit page sizes" do
|
|
58
|
+
[nil, "", "bad", 0, -1, [], true, 5000].each do |value|
|
|
59
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 500),
|
|
60
|
+
params: { per_page: value }, max_page_size: 40)
|
|
61
|
+
assert_equal 40, query.per_page, "per_page=#{value.inspect}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
test "per_page remains a positive integer when max_page_size is below 1" do
|
|
66
|
+
[0, -5, nil].each do |cap|
|
|
67
|
+
query = AdminSuite::Query.new(
|
|
68
|
+
resource_config: config_with(per_page: 30),
|
|
69
|
+
params: { per_page: "10" },
|
|
70
|
+
max_page_size: cap
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert_kind_of Integer, query.per_page, "max_page_size=#{cap.inspect}"
|
|
74
|
+
assert_operator query.per_page, :>, 0, "max_page_size=#{cap.inspect}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
test "a raising includes degrades to the unoptimized scope instead of raising" do
|
|
79
|
+
relation = RaisingIncludesRelation.new([])
|
|
80
|
+
Model.relation = relation
|
|
81
|
+
query = AdminSuite::Query.new(resource_config: config_with(per_page: 25, includes: [:nope]), params: {})
|
|
82
|
+
|
|
83
|
+
assert_same relation, query.scope
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def config_with(per_page:, includes: [])
|
|
89
|
+
Config.new(Model, IndexConfig.new(per_page, includes, [], [], nil, [], :asc))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class RemovedDeprecationsTest < ActiveSupport::TestCase
|
|
6
|
+
test "the gem has no legacy Gleania renderer implementations or defaults" do
|
|
7
|
+
refute defined?(AdminSuite::Renderers::LegacyGleania)
|
|
8
|
+
%i[prompt_template_preview messages_preview tool_args_preview turn_messages_preview].each do |key|
|
|
9
|
+
assert_nil AdminSuite::RendererRegistry.lookup_default(key)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
test "exportable is no longer a resource DSL method" do
|
|
14
|
+
refute Admin::Base::Resource.respond_to?(:exportable)
|
|
15
|
+
end
|
|
16
|
+
end
|
data/test/lib/renderer_test.rb
CHANGED
|
@@ -116,7 +116,14 @@ module AdminSuite
|
|
|
116
116
|
assert_equal 1, logged.size
|
|
117
117
|
assert_includes logged.first, key.to_s
|
|
118
118
|
assert_includes logged.first, "deprecated"
|
|
119
|
-
|
|
119
|
+
# Deliberately a hardcoded literal, not read off
|
|
120
|
+
# DEPRECATION_MESSAGE_FORMAT: reading the version out of the constant
|
|
121
|
+
# under test and asserting the message contains it is true by
|
|
122
|
+
# construction (format() only substitutes %<key>s, never the version
|
|
123
|
+
# digits), so it can never fail on a wrong retarget -- exactly the
|
|
124
|
+
# property this test exists to catch. If the removal target changes
|
|
125
|
+
# again, this literal must be updated by hand; that's the point.
|
|
126
|
+
assert_includes logged.first, "0.6.0"
|
|
120
127
|
ensure
|
|
121
128
|
AdminSuite.config.custom_renderers.delete(key)
|
|
122
129
|
end
|
|
@@ -144,6 +151,12 @@ module AdminSuite
|
|
|
144
151
|
assert_includes html, "host-class-7"
|
|
145
152
|
refute_includes html, "Hello 7"
|
|
146
153
|
ensure
|
|
154
|
+
# `register_default` seeds AdminSuite's *permanent* default store (the
|
|
155
|
+
# same one the built-ins live in), so this probe key must be removed
|
|
156
|
+
# via `unregister_default` -- unlike `unregister`, which only ever
|
|
157
|
+
# touches the explicit store and would silently leave this key behind
|
|
158
|
+
# for every test that runs afterward.
|
|
159
|
+
AdminSuite::RendererRegistry.unregister_default(key)
|
|
147
160
|
if Admin::Renderers.const_defined?(:HostBeatsDefaultProbeRenderer, false)
|
|
148
161
|
Admin::Renderers.send(:remove_const, :HostBeatsDefaultProbeRenderer)
|
|
149
162
|
end
|
|
@@ -172,12 +185,15 @@ module AdminSuite
|
|
|
172
185
|
test "a registered renderer class wins over the built-in case branches" do
|
|
173
186
|
# :json_preview has a legacy case branch; a registered class must take it over.
|
|
174
187
|
#
|
|
175
|
-
# Task 4 registers :json_preview as a permanent alias to JsonRenderer
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
188
|
+
# Task 4 registers :json_preview as a permanent alias to JsonRenderer,
|
|
189
|
+
# but via `register_default` (the gem-defaults store), not `register`
|
|
190
|
+
# (the explicit store). `lookup` below only ever reads the explicit
|
|
191
|
+
# store, so `previous` is nil here -- there is no explicit registration
|
|
192
|
+
# to save or restore, and the `ensure` branch's plain `unregister` is
|
|
193
|
+
# a safe no-op that cannot touch the permanent default alias (which
|
|
194
|
+
# only `unregister_default` could remove). This test still needs the
|
|
195
|
+
# save/restore shape below in case some future test registers
|
|
196
|
+
# :json_preview explicitly and forgets to clean up after itself.
|
|
181
197
|
previous = AdminSuite::RendererRegistry.lookup(:json_preview)
|
|
182
198
|
AdminSuite::RendererRegistry.register(:json_preview, GreetingRenderer)
|
|
183
199
|
assert_includes render_custom_section(Record.new(6), :json_preview), "Hello 6"
|