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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +203 -18
  4. data/CONTRIBUTING.md +9 -4
  5. data/README.md +21 -5
  6. data/app/assets/vendor/chart.umd.min.js +14 -0
  7. data/app/controllers/admin_suite/application_controller.rb +12 -11
  8. data/app/controllers/admin_suite/mcp_controller.rb +36 -0
  9. data/app/controllers/admin_suite/resources_controller.rb +71 -17
  10. data/app/helpers/admin_suite/base_helper.rb +187 -10
  11. data/app/javascript/admin_suite_application.js +3 -0
  12. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  13. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  14. data/app/views/admin_suite/resources/index.html.erb +25 -5
  15. data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
  16. data/app/views/layouts/admin_suite/application.html.erb +6 -0
  17. data/config/routes.rb +8 -0
  18. data/lib/admin/base/filter_builder.rb +48 -5
  19. data/lib/admin/base/resource.rb +46 -32
  20. data/lib/admin_suite/auth/host_user.rb +42 -0
  21. data/lib/admin_suite/auth/strategy.rb +1 -1
  22. data/lib/admin_suite/auth.rb +15 -0
  23. data/lib/admin_suite/authorization_context.rb +28 -0
  24. data/lib/admin_suite/configuration.rb +66 -8
  25. data/lib/admin_suite/engine.rb +1 -1
  26. data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
  27. data/lib/admin_suite/mcp/authorization.rb +68 -0
  28. data/lib/admin_suite/mcp/serializer.rb +138 -0
  29. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  30. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  31. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  32. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  33. data/lib/admin_suite/mcp.rb +53 -0
  34. data/lib/admin_suite/query.rb +71 -0
  35. data/lib/admin_suite/renderer_registry.rb +11 -0
  36. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  37. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  38. data/lib/admin_suite/version.rb +1 -1
  39. data/lib/admin_suite.rb +23 -7
  40. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  41. data/test/controllers/resources_controller_test.rb +19 -13
  42. data/test/integration/association_linking_test.rb +292 -0
  43. data/test/integration/authentication_test.rb +10 -0
  44. data/test/integration/authorization_test.rb +20 -3
  45. data/test/integration/chart_panel_test.rb +491 -0
  46. data/test/integration/dashboard_test.rb +9 -2
  47. data/test/integration/index_query_characterization_test.rb +229 -0
  48. data/test/integration/index_table_test.rb +289 -0
  49. data/test/integration/mcp_aggregate_test.rb +44 -0
  50. data/test/integration/mcp_authorization_test.rb +102 -0
  51. data/test/integration/mcp_endpoint_test.rb +89 -0
  52. data/test/integration/mcp_get_record_test.rb +62 -0
  53. data/test/integration/mcp_instrumentation_test.rb +107 -0
  54. data/test/integration/mcp_list_records_test.rb +75 -0
  55. data/test/integration/mcp_parity_test.rb +155 -0
  56. data/test/integration/mcp_release_test.rb +131 -0
  57. data/test/integration/navigation_sections_test.rb +21 -0
  58. data/test/integration/read_only_resource_test.rb +128 -2
  59. data/test/integration/searchable_select_search_test.rb +369 -0
  60. data/test/integration/show_hide_blank_test.rb +195 -0
  61. data/test/integration/toggle_test.rb +106 -0
  62. data/test/lib/auth_host_user_test.rb +52 -0
  63. data/test/lib/auth_http_basic_test.rb +10 -0
  64. data/test/lib/auth_test.rb +20 -3
  65. data/test/lib/authorization_context_test.rb +127 -0
  66. data/test/lib/definition_loader_test.rb +12 -0
  67. data/test/lib/engine_defaults_test.rb +1 -2
  68. data/test/lib/form_field_renderer_test.rb +83 -11
  69. data/test/lib/format_table_cell_test.rb +49 -0
  70. data/test/lib/index_includes_test.rb +223 -0
  71. data/test/lib/mcp_serializer_test.rb +107 -0
  72. data/test/lib/query_test.rb +91 -0
  73. data/test/lib/removed_deprecations_test.rb +16 -0
  74. data/test/lib/renderer_test.rb +23 -7
  75. data/test/publish_workflow_test.rb +63 -0
  76. data/test/test_helper.rb +73 -8
  77. metadata +76 -9
  78. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -230
  79. data/test/lib/legacy_renderer_deprecation_test.rb +0 -35
  80. 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 = ActiveModel::Name.new(self.class, nil, "Record")
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`). No test here exercises an actual validation
19
- # error, so a Hash defaulting to `[]` is sufficient.
20
- def errors = Hash.new([])
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
- name: name, type: type, required: false, label: name.to_s.humanize,
26
- readonly: false, multiple: false, creatable: false, preview: true
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
@@ -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
- assert_includes logged.first, "0.5.0"
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
- # at require time, so unlike when this test was first written —
177
- # there is now always a prior registration to restore. Unregistering
178
- # unconditionally in `ensure` would delete that alias process-wide for
179
- # every test that runs after this one (a real, order-dependent bug
180
- # this exact test used to hide).
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"