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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +42 -0
- data/.gitignore +7 -0
- data/.rspec +3 -0
- data/Gemfile +22 -0
- data/LICENSE +21 -0
- data/README.md +357 -0
- data/Rakefile +18 -0
- data/active_admin_filter_extensions.gemspec +29 -0
- data/config/locales/en.yml +10 -0
- data/lib/active_admin_filter_extensions/data_access.rb +22 -0
- data/lib/active_admin_filter_extensions/filter_defaults.rb +327 -0
- data/lib/active_admin_filter_extensions/filter_options.rb +70 -0
- data/lib/active_admin_filter_extensions/filters_form.rb +47 -0
- data/lib/active_admin_filter_extensions/notice.rb +38 -0
- data/lib/active_admin_filter_extensions/policies.rb +539 -0
- data/lib/active_admin_filter_extensions/redirect.rb +82 -0
- data/lib/active_admin_filter_extensions/required_filters.rb +83 -0
- data/lib/active_admin_filter_extensions/search_keys.rb +71 -0
- data/lib/active_admin_filter_extensions/version.rb +5 -0
- data/lib/active_admin_filter_extensions.rb +46 -0
- data/spec/data_access_spec.rb +252 -0
- data/spec/integration/extensions_container_spec.rb +38 -0
- data/spec/integration/filter_defaults_spec.rb +229 -0
- data/spec/integration/numeric_builtins_spec.rb +104 -0
- data/spec/integration/on_invalid_spec.rb +174 -0
- data/spec/integration/policies_spec.rb +204 -0
- data/spec/integration/redirect_spec.rb +204 -0
- data/spec/integration/require_any_filter_spec.rb +132 -0
- data/spec/integration/required_spec.rb +155 -0
- data/spec/integration/structural_keys_spec.rb +77 -0
- data/spec/integration/window_spec.rb +208 -0
- data/spec/integration/with_spec.rb +175 -0
- data/spec/rails_helper.rb +62 -0
- data/spec/redirect_install_spec.rb +27 -0
- data/spec/spec_helper.rb +68 -0
- data/spec/support/capybara_driver.rb +12 -0
- data/spec/support/rails_template.rb +637 -0
- data/tasks/test.rake +32 -0
- 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
|