active_admin_filter_extensions 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +42 -0
  3. data/.gitignore +7 -0
  4. data/.rspec +3 -0
  5. data/Gemfile +22 -0
  6. data/LICENSE +21 -0
  7. data/README.md +357 -0
  8. data/Rakefile +18 -0
  9. data/active_admin_filter_extensions.gemspec +29 -0
  10. data/config/locales/en.yml +10 -0
  11. data/lib/active_admin_filter_extensions/data_access.rb +22 -0
  12. data/lib/active_admin_filter_extensions/filter_defaults.rb +327 -0
  13. data/lib/active_admin_filter_extensions/filter_options.rb +70 -0
  14. data/lib/active_admin_filter_extensions/filters_form.rb +47 -0
  15. data/lib/active_admin_filter_extensions/notice.rb +38 -0
  16. data/lib/active_admin_filter_extensions/policies.rb +539 -0
  17. data/lib/active_admin_filter_extensions/redirect.rb +82 -0
  18. data/lib/active_admin_filter_extensions/required_filters.rb +83 -0
  19. data/lib/active_admin_filter_extensions/search_keys.rb +71 -0
  20. data/lib/active_admin_filter_extensions/version.rb +5 -0
  21. data/lib/active_admin_filter_extensions.rb +46 -0
  22. data/spec/data_access_spec.rb +252 -0
  23. data/spec/integration/extensions_container_spec.rb +38 -0
  24. data/spec/integration/filter_defaults_spec.rb +229 -0
  25. data/spec/integration/numeric_builtins_spec.rb +104 -0
  26. data/spec/integration/on_invalid_spec.rb +174 -0
  27. data/spec/integration/policies_spec.rb +204 -0
  28. data/spec/integration/redirect_spec.rb +204 -0
  29. data/spec/integration/require_any_filter_spec.rb +132 -0
  30. data/spec/integration/required_spec.rb +155 -0
  31. data/spec/integration/structural_keys_spec.rb +77 -0
  32. data/spec/integration/window_spec.rb +208 -0
  33. data/spec/integration/with_spec.rb +175 -0
  34. data/spec/rails_helper.rb +62 -0
  35. data/spec/redirect_install_spec.rb +27 -0
  36. data/spec/spec_helper.rb +68 -0
  37. data/spec/support/capybara_driver.rb +12 -0
  38. data/spec/support/rails_template.rb +637 -0
  39. data/tasks/test.rake +32 -0
  40. metadata +103 -0
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+ require "rack/utils"
5
+
6
+ # `required: true` promotes a filter's default from "applies to a bare request" to "applies
7
+ # whenever THIS filter's key is absent" - the mandatory window every app hand-rolled as a
8
+ # filter_defaults_apply? override. `skips_required: true` on a filter is the escape hatch: a search
9
+ # that names a unique record gets exactly what it asked for.
10
+ RSpec.describe "required: filters", type: :feature do
11
+ let!(:kept) do
12
+ Post.create!(title: "keep me", body: "keep body", status: "published",
13
+ position: 20, published_date: Date.new(2026, 6, 1), starred: true)
14
+ end
15
+ let!(:dropped) do
16
+ Post.create!(title: "drop me", body: "drop body", status: "draft",
17
+ position: 1, published_date: Date.new(2020, 1, 1), starred: false)
18
+ end
19
+
20
+ def query_of(url)
21
+ Rack::Utils.parse_nested_query(URI.parse(url).query.to_s)
22
+ end
23
+
24
+ it "applies beside the filters the request carries, in one hop" do
25
+ visit "/admin/required_posts?q%5Btitle_cont%5D=me"
26
+
27
+ expect(query_of(page.current_url)["q"])
28
+ .to eq("title_cont" => "me", "status_eq" => "published")
29
+ expect(page).to have_content("keep me")
30
+ expect(page).to have_no_content("drop me")
31
+ end
32
+
33
+ it "counts a sibling predicate of its own filter as an answer" do
34
+ # Judged on the derived key alone, the default merged in beside the admin's own condition:
35
+ # two conditions on one column, both in the canonical URL, and the notice greeting it.
36
+ visit "/admin/required_sibling_posts?q%5Btitle_eq%5D=drop%20me"
37
+
38
+ expect(query_of(page.current_url)["q"]).to eq("title_eq" => "drop me")
39
+ expect(page).to have_content("drop me")
40
+ expect(page).to have_no_content("Showing kept titles by default")
41
+ end
42
+
43
+ it "still applies beside a predicate-less stem Ransack ignores" do
44
+ # q[title] is no condition at all - counting it would switch a mandatory window off with a
45
+ # key that searches nothing.
46
+ visit "/admin/required_sibling_posts?q%5Btitle%5D=drop"
47
+
48
+ expect(query_of(page.current_url)["q"]).to include("title_cont" => "keep")
49
+ expect(page).to have_content("keep me")
50
+ end
51
+
52
+ it "applies even to a blank filters-form submission" do
53
+ # A required window has no 'show me everything' escape - that is what required means.
54
+ visit "/admin/required_posts?commit=Filter"
55
+
56
+ expect(query_of(page.current_url)["q"]).to eq("status_eq" => "published")
57
+ expect(page).to have_no_content("drop me")
58
+ end
59
+
60
+ it "keeps the admin's own value for the required filter" do
61
+ visit "/admin/required_posts?q%5Bstatus_eq%5D=draft"
62
+
63
+ expect(query_of(page.current_url)["q"]).to eq("status_eq" => "draft")
64
+ expect(page).to have_content("drop me")
65
+ expect(page).to have_no_content("keep me")
66
+ end
67
+
68
+ it "steps aside for a search that names a unique record" do
69
+ visit "/admin/required_posts?q%5Bid_eq%5D=#{dropped.id}"
70
+
71
+ expect(URI.parse(page.current_url).query).to eq("q%5Bid_eq%5D=#{dropped.id}")
72
+ expect(page).to have_content("drop me")
73
+ expect(page).to have_no_content("keep me")
74
+ end
75
+
76
+ it "keeps required: away from the rendered input" do
77
+ # required: is also a Formtastic option: leaked through the merged-flat policy keys it
78
+ # would mark the sidebar input HTML-required and block blank form submits in the browser.
79
+ visit "/admin/required_posts"
80
+
81
+ expect(page).to have_css("#filters_sidebar_section")
82
+ expect(page).to have_no_css("#filters_sidebar_section [required]", visible: :all)
83
+ end
84
+
85
+ it "greets the imposed window with the notice" do
86
+ visit "/admin/required_posts?q%5Btitle_cont%5D=me"
87
+
88
+ expect(page).to have_content("Showing published posts by default")
89
+ end
90
+
91
+ describe "required: { if: }" do
92
+ it "imposes the window beside its trigger keys" do
93
+ visit "/admin/pinned_window_posts?q%5Btitle_cont%5D=me"
94
+
95
+ expect(query_of(page.current_url)["q"])
96
+ .to eq("title_cont" => "me", "status_eq" => "published")
97
+ expect(page).to have_no_content("drop me")
98
+ end
99
+
100
+ it "stays home beside any other filter" do
101
+ visit "/admin/pinned_window_posts?q%5Bposition_eq%5D=1"
102
+
103
+ expect(query_of(page.current_url)["q"]).to eq("position_eq" => "1")
104
+ expect(page).to have_content("drop me")
105
+ end
106
+
107
+ it "still applies as a plain default to the bare index" do
108
+ visit "/admin/pinned_window_posts"
109
+
110
+ expect(query_of(page.current_url)["q"]).to eq("status_eq" => "published")
111
+ end
112
+ end
113
+
114
+ it "raises for required: with an unknown option" do
115
+ expect { visit "/admin/banana_required_option_posts?q%5Btitle_cont%5D=x" }
116
+ .to raise_error(ArgumentError, /required:.*takes true or/m)
117
+ end
118
+
119
+ it "raises for required: with no default: to fall back on" do
120
+ expect { visit "/admin/required_banana_posts" }
121
+ .to raise_error(ArgumentError, /title_cont.*required.*no default/m)
122
+ end
123
+
124
+ it "counts any of the filter's own keys as answering the group" do
125
+ # An endless default fills only the gteq slot, but an upper-only request still answered
126
+ # THIS filter - injecting the default lower bound beside it would execute a pair the
127
+ # admin never meant.
128
+ visit "/admin/required_open_posts?q%5Bpublished_date_lteq%5D=2026-06-30"
129
+
130
+ expect(query_of(page.current_url)["q"]).to eq("published_date_lteq" => "2026-06-30")
131
+ expect(page).to have_content("keep me")
132
+ expect(page).to have_content("drop me")
133
+ end
134
+
135
+ it "treats an array of blanks as unanswered" do
136
+ # ?q[k][]= survives a naive blank check ([""].blank? is false) while Ransack discards
137
+ # the condition - an unanswered window wearing an answered-looking URL.
138
+ visit "/admin/required_posts?q%5Bstatus_eq%5D%5B%5D=&q%5Btitle_cont%5D=me"
139
+
140
+ expect(query_of(page.current_url)["q"])
141
+ .to eq("title_cont" => "me", "status_eq" => "published")
142
+ expect(page).to have_no_content("drop me")
143
+ end
144
+
145
+ it "treats blank-valued keys as unanswered" do
146
+ # ?q[status_eq]= must not defeat the window: a blank clobbering the default and Ransack
147
+ # then stripping the blank is a full-table scan wearing an answered-looking URL.
148
+ visit "/admin/required_posts?q%5Bstatus_eq%5D=&q%5Btitle_cont%5D=me"
149
+
150
+ expect(query_of(page.current_url)["q"])
151
+ .to eq("title_cont" => "me", "status_eq" => "published")
152
+ expect(page).to have_no_content("drop me")
153
+ expect(page).to have_content("Showing published posts by default")
154
+ end
155
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+ require "rack/utils"
5
+
6
+ # Ransack reads more than conditions out of q: `m` switches the combinator to OR, `g` nests
7
+ # whole condition groups. No Active Admin filter form submits them, and every policy in this
8
+ # gem reasons about flat condition keys - so a structural key is not an answer, it is a
9
+ # bypass. They are stripped before anything reads the request.
10
+ RSpec.describe "structural Ransack keys", type: :feature do
11
+ before do
12
+ Post.create!(title: "keep me", body: "keep body", status: "published",
13
+ position: 20, published_date: Date.new(2026, 6, 1), starred: true)
14
+ Post.create!(title: "drop me", body: "drop body", status: "draft",
15
+ position: 1, published_date: Date.new(2020, 1, 1), starred: false)
16
+ end
17
+
18
+ def query_of(url)
19
+ Rack::Utils.parse_nested_query(URI.parse(url).query.to_s)
20
+ end
21
+
22
+ it "strips q[m]=or instead of letting it turn a required window into a full scan" do
23
+ visit "/admin/window_posts?q%5Bm%5D=or" \
24
+ "&q%5Bpublished_date_gteq%5D=2026-05-01&q%5Bpublished_date_lteq%5D=2026-06-30"
25
+
26
+ expect(query_of(page.current_url)["q"])
27
+ .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30")
28
+ expect(page).to have_content("keep me")
29
+ expect(page).to have_no_content("drop me")
30
+ end
31
+
32
+ it "strips the long-form aliases Ransack also answers to" do
33
+ # `conditions` is `c` spelled out (as `sorts` is `s`) - the alias must not walk around
34
+ # the strip, least of all on the CSV, which gets no canonicalizing hop.
35
+ visit "/admin/string_posts.csv?q%5Bconditions%5D%5B0%5D%5Btitle_cont%5D=drop"
36
+
37
+ expect(page.body).to include("keep me")
38
+ expect(page.body).not_to include("drop me")
39
+ end
40
+
41
+ it "does not let a structural-only q suppress the defaults on CSV" do
42
+ # The HTML path would self-heal with a hop; the CSV runs filtering_params directly, and
43
+ # a q that holds nothing but structure is a bare request.
44
+ visit "/admin/string_posts.csv?q%5Bg%5D%5B0%5D%5Btitle_cont%5D=drop"
45
+
46
+ expect(page.body).to include("keep me")
47
+ expect(page.body).not_to include("drop me")
48
+ end
49
+
50
+ it "leaves a plain resource's own grouping alone" do
51
+ # q[g] is the standard Active Admin workaround for an OR search. On a resource that
52
+ # declares no extension there is no policy to bypass, and stripping it answered a narrowed
53
+ # export with the whole table.
54
+ visit "/admin/plain_posts.csv?q%5Bg%5D%5B0%5D%5Btitle_cont%5D=keep"
55
+
56
+ expect(page.body).to include("keep me")
57
+ expect(page.body).not_to include("drop me")
58
+ end
59
+
60
+ it "keeps a Ransack sort, which is no condition" do
61
+ # Stripped, q[s] was dropped from the search and erased from the canonical URL - the 302
62
+ # handed back a link that had lost the sort it was made for.
63
+ visit "/admin/posts?q%5Bs%5D=title+desc"
64
+
65
+ expect(query_of(page.current_url)["q"])
66
+ .to eq("s" => "title desc", "status_eq" => "published",
67
+ "published_date_gteq" => "2026-01-01")
68
+ end
69
+
70
+ it "strips q[g] groupings no value policy ever saw" do
71
+ visit "/admin/guarded_posts?q%5Bg%5D%5B0%5D%5Btitle_cont%5D=keep123"
72
+
73
+ expect(query_of(page.current_url)["q"]).to eq("title_cont" => "keep")
74
+ expect(page).to have_content("keep me")
75
+ expect(page).to have_no_content("drop me")
76
+ end
77
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+ require "rack/utils"
5
+
6
+ # `max_range:` and `min:` on a two-ended filter - the 90-day caps and retention floors three
7
+ # of the four inventoried apps hand-rolled. A violation resets the PAIR: both bounds dropped,
8
+ # one error flashed, the declared default refills.
9
+ RSpec.describe "Window policies: max_range and min", type: :feature do
10
+ before do
11
+ Post.create!(title: "keep me", body: "keep body", status: "published",
12
+ position: 20, published_date: Date.new(2026, 6, 1), starred: true)
13
+ Post.create!(title: "drop me", body: "drop body", status: "draft",
14
+ position: 1, published_date: Date.new(2020, 1, 1), starred: false)
15
+ end
16
+
17
+ def query_of(url)
18
+ Rack::Utils.parse_nested_query(URI.parse(url).query.to_s)
19
+ end
20
+
21
+ def visit_window(gteq: nil, lteq: nil)
22
+ q = {}
23
+ q["q[published_date_gteq]"] = gteq if gteq
24
+ q["q[published_date_lteq]"] = lteq if lteq
25
+ visit "/admin/window_posts?#{Rack::Utils.build_query(q)}"
26
+ end
27
+
28
+ it "refuses a range wider than max_range and falls back to the default" do
29
+ visit_window(gteq: "2020-07-01", lteq: "2026-06-30")
30
+
31
+ expect(page).to have_content("allows a maximum date range of 90 days")
32
+ expect(query_of(page.current_url)["q"])
33
+ .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30")
34
+ expect(page).to have_content("keep me")
35
+ expect(page).to have_no_content("Showing the default window")
36
+ end
37
+
38
+ it "refuses an inverted range" do
39
+ visit_window(gteq: "2026-06-10", lteq: "2026-06-01")
40
+
41
+ expect(page).to have_content("invalid date range value")
42
+ expect(query_of(page.current_url)["q"])
43
+ .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30")
44
+ end
45
+
46
+ it "keeps a range exactly as wide as the cap" do
47
+ # Pins > (not >=): under a gem regression both suites would stay green while the
48
+ # inclusive boundary silently flipped.
49
+ visit_window(gteq: "2026-03-01", lteq: "2026-05-30")
50
+
51
+ expect(query_of(page.current_url)["q"])
52
+ .to eq("published_date_gteq" => "2026-03-01", "published_date_lteq" => "2026-05-30")
53
+ expect(page).to have_no_content("maximum date range")
54
+ end
55
+
56
+ it "refuses an upper bound alone under a lone min: floor" do
57
+ # No lower bound means everything before the retention floor - refused and reset even
58
+ # when no max_range: is declared beside the floor.
59
+ visit "/admin/floor_posts?q%5Bpublished_date_lteq%5D=2026-06-30"
60
+
61
+ expect(page).to have_content("Published date: starts no earlier than 2020-06-01 (retention).")
62
+ expect(query_of(page.current_url)["q"])
63
+ .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30")
64
+ end
65
+
66
+ it "names a sub-day cap in the units it was declared in" do
67
+ # Divided into whole days, a cap shorter than one read "a maximum date range of 0 days".
68
+ visit "/admin/short_window_posts?q%5Bpublished_date_gteq%5D=2026-01-01" \
69
+ "&q%5Bpublished_date_lteq%5D=2026-06-30"
70
+
71
+ expect(page).to have_content("maximum date range of 6 hours")
72
+ expect(page).to have_no_content("of 0 days")
73
+ end
74
+
75
+ it "imposes nothing when a min: Proc resolves to nil" do
76
+ # An upper bound alone: both floor lookups are on this path, and nil.in_time_zone 500'd
77
+ # the request the floor was only meant to narrow.
78
+ visit "/admin/nil_floor_posts?q%5Bpublished_date_lteq%5D=2026-06-30"
79
+
80
+ expect(page).to have_no_content("cannot start before")
81
+ expect(page).to have_content("keep me")
82
+ expect(page).to have_content("drop me")
83
+ end
84
+
85
+ it "resets the whole pair when a value policy rejects one bound" do
86
+ # Half the admin's window completed from the default pair is a window nobody asked for
87
+ # and nothing validated - the CSV would execute it without the HTML's healing hop.
88
+ recent = (Date.current - 30).strftime("%Y-%m-%d")
89
+ visit "/admin/validated_window_posts.csv?q%5Bpublished_date_gteq%5D=#{recent}" \
90
+ "&q%5Bpublished_date_lteq%5D=2026-07-01X"
91
+
92
+ expect(page.body).to include("keep me")
93
+ end
94
+
95
+ it "raises for a default whose sanitizer widens it past its own cap" do
96
+ # The window is judged on what is actually stored - after the sanitizer, not before.
97
+ expect { visit "/admin/banana_sanitized_window_posts" }
98
+ .to raise_error(ArgumentError, /violates its own window policy/)
99
+ end
100
+
101
+ it "refuses a bound that is not a plain string" do
102
+ # ?q[..._gteq][]=date parses as a date via Array#to_s, then Ransack silently drops the
103
+ # wrapped condition - an in-cap-looking URL running the unbounded scan the cap refuses.
104
+ visit "/admin/window_posts?q%5Bpublished_date_gteq%5D%5B%5D=2026-06-01"
105
+
106
+ expect(page).to have_content("invalid date range value")
107
+ expect(query_of(page.current_url)["q"])
108
+ .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30")
109
+ end
110
+
111
+ it "refuses an unparseable bound" do
112
+ visit_window(gteq: "garbage", lteq: "2026-06-01")
113
+
114
+ expect(page).to have_content("invalid date range value")
115
+ expect(query_of(page.current_url)["q"])
116
+ .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30")
117
+ end
118
+
119
+ it "refuses an upper bound alone" do
120
+ # An upper bound alone is an infinitely wide window - the outage query the cap refuses.
121
+ visit_window(lteq: "2026-06-30")
122
+
123
+ expect(page).to have_content("allows a maximum date range of 90 days")
124
+ expect(query_of(page.current_url)["q"])
125
+ .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30")
126
+ end
127
+
128
+ it "refuses a bound before the min: floor, in the application's own words" do
129
+ # The message comes from the app's locale file overriding the gem key - the i18n seam.
130
+ visit_window(gteq: "2020-01-01", lteq: "2020-03-01")
131
+
132
+ expect(page).to have_content("Published date: starts no earlier than 2020-06-01 (retention).")
133
+ expect(query_of(page.current_url)["q"])
134
+ .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30")
135
+ end
136
+
137
+ it "treats a lower bound left open as unbounded" do
138
+ # An upper bound alone is an infinitely wide window - on the partitioned tables this
139
+ # feature targets, that is the outage query the cap exists to refuse.
140
+ visit_window(lteq: "2026-06-30")
141
+
142
+ expect(page).to have_content("allows a maximum date range of 90 days")
143
+ expect(query_of(page.current_url)["q"])
144
+ .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30")
145
+ end
146
+
147
+ it "measures an open upper bound against now" do
148
+ visit_window(gteq: "2020-07-01")
149
+
150
+ expect(page).to have_content("allows a maximum date range of 90 days")
151
+ end
152
+
153
+ it "lets a future lower bound with no upper bound through" do
154
+ # It selects a not-yet-written window - nothing to cap. (A hard loop in one of the apps
155
+ # came from measuring exactly this against Time.current.) Computed, not a literal: a
156
+ # hardcoded year goes red the day the clock catches up with it.
157
+ future = (Date.current + 60).strftime("%Y-%m-%d")
158
+ visit_window(gteq: future)
159
+
160
+ expect(page).to have_no_content("allows a maximum date range")
161
+ expect(query_of(page.current_url)["q"]).to eq("published_date_gteq" => future)
162
+ expect(page).to have_no_content("keep me")
163
+ end
164
+
165
+ it "keeps a range inside every limit untouched" do
166
+ visit_window(gteq: "2026-05-01", lteq: "2026-06-30")
167
+
168
+ expect(query_of(page.current_url)["q"])
169
+ .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30")
170
+ expect(page).to have_content("keep me")
171
+ expect(page).to have_no_content("invalid date range")
172
+ end
173
+
174
+ it "raises for a beginless default under max_range:" do
175
+ # Unbounded below the cap: accepted, it would run the over-cap scan on every bare visit.
176
+ # The bound must land in the lteq slot to be judged.
177
+ expect { visit "/admin/banana_beginless_window_posts" }
178
+ .to raise_error(ArgumentError, /violates its own window policy/)
179
+ end
180
+
181
+ it "raises when the fallback default itself violates the policy" do
182
+ # Resetting into a default the policy would refuse again is a loop, not a fallback -
183
+ # that is a configuration error and says so loudly.
184
+ expect { visit "/admin/banana_window_posts?q%5Bpublished_date_gteq%5D=2020-07-01" }
185
+ .to raise_error(ArgumentError, /default.*violates its own/m)
186
+ end
187
+
188
+ describe "the floor across time zones" do
189
+ # Date#to_time speaks the SERVER zone while the bounds are parsed in Time.zone; with the
190
+ # two apart, a bound exactly on the floor date was spuriously rejected (or genuinely
191
+ # earlier ones silently passed, offset depending).
192
+ around do |example|
193
+ old_tz = ENV["TZ"]
194
+ ENV["TZ"] = "America/New_York"
195
+ example.run
196
+ ensure
197
+ ENV["TZ"] = old_tz
198
+ end
199
+
200
+ it "accepts a bound exactly on the floor whatever the server zone" do
201
+ visit_window(gteq: "2020-06-01", lteq: "2020-06-20")
202
+
203
+ expect(page).to have_no_content("cannot start before")
204
+ expect(query_of(page.current_url)["q"])
205
+ .to eq("published_date_gteq" => "2020-06-01", "published_date_lteq" => "2020-06-20")
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+ require "rack/utils"
5
+
6
+ # `with:` - a cross-filter group: either every key or none. Half a pair is reset to the
7
+ # declared defaults with a warning (the monthly-invoices year/month shape), whichever side
8
+ # the half came from.
9
+ RSpec.describe "with: filters", type: :feature do
10
+ before do
11
+ Post.create!(title: "keep me", body: "keep body", status: "published",
12
+ position: 20, published_date: Date.new(2026, 6, 1), starred: true)
13
+ Post.create!(title: "drop me", body: "drop body", status: "draft",
14
+ position: 1, published_date: Date.new(2020, 1, 1), starred: false)
15
+ end
16
+
17
+ def query_of(url)
18
+ Rack::Utils.parse_nested_query(URI.parse(url).query.to_s)
19
+ end
20
+
21
+ it "resets a half-answered group to the defaults, saying so" do
22
+ visit "/admin/paired_posts?q%5Bstatus_eq%5D=draft"
23
+
24
+ expect(page).to have_content("must be given together")
25
+ expect(query_of(page.current_url)["q"])
26
+ .to eq("status_eq" => "published", "starred_eq" => "true")
27
+ expect(page).to have_content("keep me")
28
+ expect(page).to have_no_content("drop me")
29
+ end
30
+
31
+ it "names the filters without their predicates, whatever the predicate" do
32
+ # The old humanizer knew _eq/_in/_cont only; a _gteq pair flashed "Published date gteq".
33
+ visit "/admin/paired_date_posts?q%5Bpublished_date_gteq%5D=2026-01-01"
34
+
35
+ expect(page).to have_content("Position and Published date filters must be given together")
36
+ end
37
+
38
+ it "trips from the side that declared nothing" do
39
+ # PairedDatePost declares with: only on published_date; the partner must trip the
40
+ # same group.
41
+ visit "/admin/paired_date_posts?q%5Bposition_eq%5D=1"
42
+
43
+ expect(page).to have_content("must be given together")
44
+ expect(URI.parse(page.current_url).query).to be_nil
45
+ end
46
+
47
+ it "warns once for mirrored declarations" do
48
+ # PairedPost declares with: on both members - the two declarations are one group.
49
+ visit "/admin/paired_posts?q%5Bstatus_eq%5D=draft"
50
+
51
+ expect(page).to have_css(".flash_warning", count: 1)
52
+ end
53
+
54
+ it "does not count a blank-array partner as answered" do
55
+ # q[partner][]= survives a raw present? check while normalization drops it - on the CSV,
56
+ # which gets no canonicalizing hop, half the group would execute silently.
57
+ visit "/admin/paired_posts.csv?q%5Bstatus_eq%5D=draft&q%5Bstarred_eq%5D%5B%5D="
58
+
59
+ expect(page.body).to include("keep me")
60
+ expect(page.body).not_to include("drop me")
61
+ end
62
+
63
+ it "counts a two-ended member answered by either bound" do
64
+ # Each bound counted as a group key of its own, so one date bound beside the partner read
65
+ # as two of three: both reset, and with no defaults the whole table rendered - the scan the
66
+ # guard exists to prevent.
67
+ visit "/admin/paired_date_posts?q%5Bpublished_date_gteq%5D=2026-01-01&q%5Bposition_eq%5D=20"
68
+
69
+ expect(page).to have_no_content("must be given together")
70
+ expect(page).to have_content("keep me")
71
+ expect(page).to have_no_content("drop me")
72
+ end
73
+
74
+ it "counts a two-ended member whose default fills only one end" do
75
+ # Same arithmetic on the fallback side: the half-open default answered one of three keys
76
+ # and read as a half-declared group.
77
+ visit "/admin/open_paired_posts"
78
+
79
+ expect(query_of(page.current_url)["q"])
80
+ .to eq("published_date_gteq" => "2026-05-01", "position_eq" => "20")
81
+ expect(page).to have_content("keep me")
82
+ expect(page).to have_no_content("drop me")
83
+ end
84
+
85
+ it "keeps separately declared partners on one attribute distinct" do
86
+ # Two single-ended filters are two members, not one two-ended input: grouping them by the
87
+ # stripped attribute made half an answer pass as a whole group.
88
+ visit "/admin/split_pair_posts?q%5Bposition_gteq%5D=10"
89
+
90
+ expect(page).to have_content("must be given together")
91
+ expect(page).to have_content("keep me")
92
+ expect(page).to have_content("drop me")
93
+ end
94
+
95
+ it "lets both halves of a split pair through" do
96
+ visit "/admin/split_pair_posts?q%5Bposition_gteq%5D=10&q%5Bposition_lteq%5D=30"
97
+
98
+ expect(page).to have_no_content("must be given together")
99
+ expect(page).to have_content("keep me")
100
+ expect(page).to have_no_content("drop me")
101
+ end
102
+
103
+ it "raises when only one half of a split pair declares a default" do
104
+ expect { visit "/admin/banana_split_pair_posts" }
105
+ .to raise_error(ArgumentError, /answer the whole\s+group or none/m)
106
+ end
107
+
108
+ it "lets a fully-answered group through" do
109
+ visit "/admin/paired_posts?q%5Bstatus_eq%5D=draft&q%5Bstarred_eq%5D=false"
110
+
111
+ expect(page).to have_no_content("must be given together")
112
+ expect(page).to have_content("drop me")
113
+ expect(page).to have_no_content("keep me")
114
+ end
115
+
116
+ it "treats overlapping groups as one question" do
117
+ # [title,status] and [status,starred] share status: answering title+status must not
118
+ # pass the first group and then have the second reset status out from under it - on the
119
+ # CSV, which gets no second chance.
120
+ visit "/admin/chained_pair_posts.csv?q%5Btitle_cont%5D=keep&q%5Bstatus_eq%5D=published"
121
+
122
+ expect(page.body).to include("keep me")
123
+ expect(page.body).to include("drop me")
124
+ end
125
+
126
+ it "pulls the partner's default along with a required refill" do
127
+ # status is required-with-default and with: starred; starred defaults to false. The
128
+ # refill must carry both, or the search runs the half-group enforcement never saw.
129
+ visit "/admin/required_pair_posts.csv?q%5Btitle_cont%5D=keep"
130
+
131
+ expect(page.body).not_to include("keep me")
132
+ expect(page.body).not_to include("drop me")
133
+ end
134
+
135
+ it "carries the whole group through the redirect on a required refill" do
136
+ visit "/admin/required_pair_posts?q%5Btitle_cont%5D=keep"
137
+
138
+ expect(query_of(page.current_url)["q"])
139
+ .to eq("title_cont" => "keep", "status_eq" => "published", "starred_eq" => "false")
140
+ end
141
+
142
+ it "imposes nothing when a member's default resolves to nil" do
143
+ # A nil Proc result is how a per-admin default opts out. Read as a missing declaration it
144
+ # raised the half-group config error on every bare visit, per admin.
145
+ visit "/admin/nil_paired_posts"
146
+
147
+ expect(page).to have_no_content("must be given together")
148
+ expect(URI.parse(page.current_url).query).to be_nil
149
+ expect(page).to have_content("keep me")
150
+ expect(page).to have_content("drop me")
151
+ end
152
+
153
+ it "says nothing about a default slice the suppressed group is not showing" do
154
+ # The group imposed nothing, so the collection is unfiltered - and the notice announced a
155
+ # default slice over the whole table, which is the notice lying about the page.
156
+ visit "/admin/nil_paired_posts"
157
+
158
+ expect(page).to have_no_content("Showing published starred posts by default")
159
+ expect(page).to have_content("keep me")
160
+ expect(page).to have_content("drop me")
161
+ end
162
+
163
+ it "raises when defaults answer only half the group" do
164
+ # The fallback is held to the same all-or-none contract the enforcement imposes.
165
+ expect { visit "/admin/banana_half_paired_posts" }
166
+ .to raise_error(ArgumentError, /answer the whole\s+group or none/m)
167
+ end
168
+
169
+ it "says nothing on a bare visit" do
170
+ visit "/admin/paired_posts"
171
+
172
+ expect(page).to have_no_content("must be given together")
173
+ expect(page).to have_content("keep me")
174
+ end
175
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Boots the generated sample app. Only the integration specs need this - the unit specs run
4
+ # against a stand-in controller and stay in spec_helper.
5
+
6
+ $LOAD_PATH.unshift(File.expand_path("support", __dir__))
7
+
8
+ ENV["BUNDLE_GEMFILE"] = File.expand_path("../Gemfile", __dir__)
9
+ require "bundler"
10
+ Bundler.setup
11
+
12
+ ENV["RAILS_ENV"] = "test"
13
+ require "rails"
14
+ ENV["RAILS"] = Rails.version
15
+ ENV["RAILS_ROOT"] = File.expand_path("rails/rails-#{ENV['RAILS']}", __dir__)
16
+
17
+ unless File.exist?(ENV["RAILS_ROOT"])
18
+ # A generation that dies midway leaves a partial app the File.exist? guard would then skip
19
+ # forever - fail the run instead of limping into it.
20
+ system("rake setup") ||
21
+ abort("rake setup failed - remove #{ENV['RAILS_ROOT']} before retrying")
22
+ end
23
+
24
+ # An app generated from an older template would silently pass against resources the template
25
+ # no longer declares - refuse it instead.
26
+ require "digest"
27
+ template_digest = Digest::SHA256.file(File.expand_path("support/rails_template.rb", __dir__)).hexdigest
28
+ stamp = File.join(ENV["RAILS_ROOT"], ".rails_template_digest")
29
+ unless File.exist?(stamp) && File.read(stamp).strip == template_digest
30
+ abort "#{ENV['RAILS_ROOT']} was generated from an older rails_template.rb - " "remove it (git clean -fdx spec/rails) and rerun, rake setup regenerates it"
31
+ end
32
+
33
+ require "active_model"
34
+ # Required before Active Admin so that Ransack loads correctly.
35
+ require "active_record"
36
+ require "action_view"
37
+ require "active_admin"
38
+ ActiveAdmin.application.load_paths = ["#{ENV['RAILS_ROOT']}/app/admin"]
39
+ require "#{ENV['RAILS_ROOT']}/config/environment"
40
+
41
+ # Specs are about filters, not about signing in.
42
+ ActiveAdmin.application.authentication_method = false
43
+ ActiveAdmin.application.current_user_method = false
44
+
45
+ require "rspec/rails"
46
+ require "capybara/rails"
47
+ require "capybara/rspec"
48
+ require "database_cleaner/active_record"
49
+ require "capybara_driver"
50
+
51
+ RSpec.configure do |config|
52
+ config.use_transactional_fixtures = false
53
+
54
+ config.before(:suite) do
55
+ ActiveRecord::Migration.maintain_test_schema!
56
+ DatabaseCleaner.strategy = :truncation
57
+ DatabaseCleaner.clean_with(:truncation)
58
+ end
59
+
60
+ config.before { DatabaseCleaner.start }
61
+ config.after { DatabaseCleaner.clean }
62
+ end