rails-contact 0.1.13 → 0.1.16

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.
@@ -0,0 +1,91 @@
1
+ # Configuration reference
2
+
3
+ Every setting on `Rails::Contact::Configuration`, with its default and what the engine
4
+ does with it. Set them in `config/initializers/rails_contact.rb`:
5
+
6
+ ```ruby
7
+ Rails::Contact.configure do |config|
8
+ config.search_backend = :database
9
+ # ...
10
+ end
11
+ ```
12
+
13
+ All settings have defaults, so you only need to list the ones you change.
14
+
15
+ ## Settings
16
+
17
+ | Setting | Type | Default | Environment variable | Description |
18
+ |---|---|---|---|---|
19
+ | `contact_class_name` | String | `"Rails::Contact::Contact"` | — | Name of the contact model. Informational in the current version — the engine references `Rails::Contact::Contact` directly. |
20
+ | `elasticsearch_url` | String | `"http://127.0.0.1:9200"` | `ELASTICSEARCH_URL` | URL of the Elasticsearch cluster used by the `:elasticsearch` backend. |
21
+ | `search_backend` | Symbol | `:elasticsearch` | — | Search backend. `:elasticsearch` uses Elasticsearch (with a database fallback on error); any other value uses the database backend. |
22
+ | `default_per_page` | Integer | `25` | — | Default page size for search. Capped at 100 per request. |
23
+ | `inherit_host_layout` | Boolean | `true` | — | When `true`, engine pages render inside the host layout named `application`. When `false`, the engine uses its own layout and bundled JavaScript. |
24
+ | `google_sync_enabled` | Boolean | `false` | `RAILS_CONTACT_GOOGLE_SYNC_ENABLED` (in the generated initializer) | Master switch for Google Contacts sync. The sync controller actions and jobs no-op when `false`. |
25
+ | `google_sync_ui_on_index` | Boolean | `true` | — | When `true`, the index renders the gem's Google sync panel partial. Set `false` to supply your own UI. |
26
+ | `google_max_contacts` | Integer | `25_000` | — | Cap on the rolling-window sync. `Contact.sync_window` limits to the newest N contacts by `updated_at`. |
27
+ | `google_token_path` | String | `"tmp/rails_contact_google_token.json"` | `RAILS_CONTACT_GOOGLE_TOKEN_PATH` | Path to the JSON file holding the OAuth access token. `TokenStore` reads the `access_token` key from it. |
28
+ | `google_client_id` | String / nil | `nil` | `GOOGLE_CLIENT_ID` | OAuth client id. Host-facing: a place to keep the value for your own OAuth flow. Not consumed by the engine. |
29
+ | `google_client_secret` | String / nil | `nil` | `GOOGLE_CLIENT_SECRET` | OAuth client secret. Host-facing, not consumed by the engine. |
30
+ | `google_redirect_uri` | String / nil | `nil` | `GOOGLE_REDIRECT_URI` | OAuth redirect URI. Host-facing, not consumed by the engine. |
31
+ | `google_contact_family_name_suffix` | String / nil | `nil` | `RAILS_CONTACT_GOOGLE_CONTACT_FAMILY_NAME_SUFFIX` | Optional suffix appended to `familyName` in Google People payloads only. Not stored on the contact. Blank disables it. |
32
+ | `rolling_window_sort` | Symbol | `:updated_at` | — | Intended sort column for the rolling window. Reserved: the current `sync_window` scope orders by `updated_at, id` and does not read this setting. |
33
+ | `reset_index_on_boot` | Boolean | `false` | — | Reserved: defined but not read by the current engine. |
34
+ | `metadata_filters` | Hash | `{}` | — | Declarative filters over `Contact#metadata`. Database backend only. See below. |
35
+ | `metadata_sorts` | Hash | `{}` | — | Declarative `?sort=` options over numeric metadata. Database backend only. See below. |
36
+
37
+ Settings marked "reserved" or "not consumed by the engine" are defined on the
38
+ configuration object and safe to set, but the current release does not act on them.
39
+ They are documented here for completeness.
40
+
41
+ ## Metadata filters and sorts
42
+
43
+ Contacts carry app-specific values in the `metadata` JSON column. `metadata_filters`
44
+ and `metadata_sorts` let the host decide which keys are filterable and sortable. The
45
+ engine then permits the request params, normalizes multi-selects, guards the SQL, and
46
+ applies the filter. These apply to the **database backend only**; the Elasticsearch
47
+ backend ignores them.
48
+
49
+ ```ruby
50
+ Rails::Contact.configure do |config|
51
+ config.metadata_filters = {
52
+ # request param name => filter declaration
53
+ "tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm standard] },
54
+ "min_pax" => { key: "pax", type: :min_integer },
55
+ "min_score" => { key: "score", type: :min_numeric },
56
+ "vip" => { key: "tags", type: :tag, tag: "vip" }
57
+ }
58
+ config.metadata_sorts = {
59
+ "score" => { key: "score" } # ?sort=score orders by metadata score, highest first
60
+ }
61
+ end
62
+ ```
63
+
64
+ `key:` is the metadata key to read. It is declared in code, never taken from a request
65
+ param, and must be a plain identifier matching `[a-zA-Z0-9_]+` — anything else raises,
66
+ as a guard against interpolating an unsafe fragment into SQL.
67
+
68
+ ### Filter types
69
+
70
+ - `:values` — multi-select. Matches when `metadata->>key` equals any selected value.
71
+ An optional `allowed:` whitelist discards anything outside it. A scalar param (a
72
+ legacy bookmark such as `?tier=hot`) is coerced to a one-element selection.
73
+ - `:min_integer` / `:min_numeric` — numeric floor. Stored values that are not numbers
74
+ are filtered out rather than cast, so an imported `"TBD"` cannot raise. `:min_numeric`
75
+ also accepts decimals.
76
+ - `:tag` — checkbox. Matches when the metadata key (a JSON array) contains `tag:`. The
77
+ param value `"1"` switches it on.
78
+ - `:exclude` — hides rows whose key equals `value:`. Add `default: :on` to apply the
79
+ filter even when the request param is absent (first page load, bookmarks); an
80
+ explicit `"0"` shows everything — built for default-on "hide test data" checkboxes.
81
+ Rows missing the key always pass, so unclassified data is never hidden.
82
+
83
+ ### Sorts
84
+
85
+ A metadata sort orders descending, puts non-numeric or missing values last, and breaks
86
+ ties by recency. Only sorts declared in `metadata_sorts` apply — an unknown `?sort=`
87
+ value is ignored.
88
+
89
+ A metadata sort is dropped while a free-text `q` search is active. The search branch
90
+ runs `SELECT DISTINCT`, and PostgreSQL rejects ordering by an expression that is not in
91
+ the select list, so search results keep recency order.
@@ -6,7 +6,8 @@ module Rails
6
6
  :google_client_id, :google_client_secret, :google_redirect_uri,
7
7
  :google_token_path, :reset_index_on_boot, :default_per_page,
8
8
  :inherit_host_layout,
9
- :google_contact_family_name_suffix
9
+ :google_contact_family_name_suffix,
10
+ :metadata_filters, :metadata_sorts
10
11
 
11
12
  def initialize
12
13
  @contact_class_name = "Rails::Contact::Contact"
@@ -29,6 +30,39 @@ module Rails
29
30
  # importmap/Turbo match the rest of the app. Engine CSS and nested-field JS are still
30
31
  # injected from gem templates so behavior does not depend on the engine layout asset tags.
31
32
  @inherit_host_layout = true
33
+ # Declarative filters over Contact#metadata, keyed by the request param
34
+ # name. The host app decides which metadata keys are filterable; the
35
+ # engine permits the params, guards the SQL, and applies the filter
36
+ # (database backend only). Example:
37
+ #
38
+ # config.metadata_filters = {
39
+ # "tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm standard] },
40
+ # "min_pax" => { key: "pax", type: :min_integer },
41
+ # "min_score" => { key: "score", type: :min_numeric },
42
+ # "vip" => { key: "tags", type: :tag, tag: "vip" }
43
+ # }
44
+ #
45
+ # :values — multi-select; matches metadata->>key IN (...). Optional
46
+ # :allowed whitelist discards anything else.
47
+ # :min_integer — numeric floor over an integer-ish metadata string;
48
+ # non-numeric stored values are filtered out, never cast.
49
+ # :min_numeric — same, but accepts decimals.
50
+ # :tag — checkbox; matches when the metadata key (a JSON array)
51
+ # contains :tag. Param value "1" switches it on.
52
+ # :exclude — hides rows whose key equals :value. Add default: :on
53
+ # to apply it even when the param is absent; an
54
+ # explicit "0" shows everything. Rows missing the key
55
+ # always pass.
56
+ @metadata_filters = {}
57
+ # Sort options over numeric metadata, keyed by the ?sort= param value:
58
+ #
59
+ # config.metadata_sorts = { "score" => { key: "score" } }
60
+ #
61
+ # Sorts descending, non-numeric values last, ties broken by recency.
62
+ # Ignored while a free-text q search is active: the search branch runs
63
+ # SELECT DISTINCT and PostgreSQL rejects ordering by an expression that
64
+ # is not in the select list, so search results keep recency order.
65
+ @metadata_sorts = {}
32
66
  end
33
67
  end
34
68
  end
@@ -3,7 +3,22 @@ module Rails
3
3
  module Search
4
4
  module Backends
5
5
  class Database
6
+ # Cap user input so the LIKE pattern stays bounded; 200 chars covers
7
+ # any realistic name/email/phone substring.
8
+ MAX_QUERY_LENGTH = 200
9
+
10
+ # Configured metadata keys are interpolated into SQL fragments, so
11
+ # they must be plain identifiers. This is a foot-gun guard for host
12
+ # developers, not a user-input path — params never reach the key.
13
+ METADATA_KEY_FORMAT = /\A[a-zA-Z0-9_]+\z/
14
+
6
15
  def search(query, filters, page:, per_page:)
16
+ query = sanitize_query(query)
17
+ # Metadata sort cannot combine with free-text search: the search
18
+ # branch below runs SELECT DISTINCT, and PostgreSQL rejects ORDER BY
19
+ # expressions that are not in the select list. Searching means
20
+ # hunting a specific contact anyway, so q results keep recency order.
21
+ filters = filters.except("sort") if query.present?
7
22
  offset = (page - 1) * per_page
8
23
  scope = Contact.includes(:emails, :phones, :labels).recent_first
9
24
  scope = apply_filters(scope, filters)
@@ -33,6 +48,16 @@ module Rails
33
48
 
34
49
  private
35
50
 
51
+ # Escape LIKE metacharacters (% _ \) so a user typing "%" can't widen
52
+ # the match to every row, and cap length to keep the pattern bounded.
53
+ # The backend builds raw "%…%" LIKE patterns, so this guard belongs
54
+ # here — host apps should not have to wrap search() to stay safe.
55
+ def sanitize_query(query)
56
+ return query if query.blank?
57
+
58
+ ActiveRecord::Base.sanitize_sql_like(query.to_s[0, MAX_QUERY_LENGTH])
59
+ end
60
+
36
61
  def apply_filters(scope, filters)
37
62
  scoped = scope
38
63
  scoped = scoped.where(current_city: filters["city"]) if filters["city"].present?
@@ -58,12 +83,133 @@ module Rails
58
83
  scoped = scoped.where("metadata->>'contact_created_at' <= ?", filters["contact_created_at_end"])
59
84
  end
60
85
 
86
+ # csv_import_id is a multi-select filter: it may be a single id or an
87
+ # array of ids. region above is already array-safe via where(region_name:),
88
+ # but this JSON-extraction predicate needs an explicit IN. A single id
89
+ # yields IN ('5'), identical to the old = '5'.
61
90
  if filters["csv_import_id"].present?
62
- scoped = scoped.where("metadata->>'csv_import_id' = ?", filters["csv_import_id"].to_s)
91
+ ids = Array(filters["csv_import_id"]).map(&:to_s).reject(&:blank?)
92
+ scoped = scoped.where("metadata->>'csv_import_id' IN (?)", ids) if ids.any?
63
93
  end
64
94
 
95
+ scoped = apply_metadata_filters(scoped, filters)
96
+ apply_metadata_sort(scoped, filters["sort"])
97
+ end
98
+
99
+ # Host-configured filters over Contact#metadata — see
100
+ # Configuration#metadata_filters for the declaration format. Every
101
+ # stored value came from user data (CSV imports, forms), so numeric
102
+ # comparisons never cast blindly: non-numeric values are filtered
103
+ # out by a guard instead of raising.
104
+ def apply_metadata_filters(scoped, filters)
105
+ Rails::Contact.configuration.metadata_filters.each do |param, config|
106
+ value = filters[param.to_s]
107
+ # A blank param means "not filtering" — except for default-on
108
+ # filters (config default: :on), which apply until the user
109
+ # explicitly switches them off with a false-y value ("0").
110
+ next if value.blank? && config[:default] != :on
111
+
112
+ key = metadata_key!(config.fetch(:key))
113
+ scoped = case config.fetch(:type)
114
+ when :values then apply_values_filter(scoped, key, value, config[:allowed])
115
+ when :min_integer then apply_min_filter(scoped, key, value, decimals: false)
116
+ when :min_numeric then apply_min_filter(scoped, key, value, decimals: true)
117
+ when :tag then apply_tag_filter(scoped, key, value, config.fetch(:tag))
118
+ when :exclude then apply_exclude_filter(scoped, key, value, config.fetch(:value))
119
+ else
120
+ raise ArgumentError, "unknown metadata filter type #{config[:type].inspect} for #{param.inspect}"
121
+ end
122
+ end
65
123
  scoped
66
124
  end
125
+
126
+ def apply_values_filter(scoped, key, value, allowed)
127
+ values = Array(value).map(&:to_s).reject(&:blank?)
128
+ values &= allowed.map(&:to_s) if allowed
129
+ return scoped if values.empty?
130
+
131
+ scoped.where("metadata->>'#{key}' IN (?)", values)
132
+ end
133
+
134
+ def apply_min_filter(scoped, key, value, decimals:)
135
+ floor = decimals ? value.to_f : value.to_i
136
+ return scoped unless floor.positive?
137
+
138
+ if postgres?(scoped)
139
+ # {0,1} instead of the ? quantifier: Rails' bind sanitizer counts
140
+ # every literal ? in the fragment as a placeholder, even inside a
141
+ # quoted regex.
142
+ pattern = decimals ? "^[0-9]+(\\.[0-9]+){0,1}$" : "^[0-9]+$"
143
+ cast = decimals ? "numeric" : "int"
144
+ scoped.where("metadata->>'#{key}' ~ '#{pattern}' AND (metadata->>'#{key}')::#{cast} >= ?", floor)
145
+ else
146
+ # SQLite (test harness): CAST never raises — junk casts to 0,
147
+ # which a positive floor excludes on its own.
148
+ scoped.where("CAST(metadata->>'#{key}' AS REAL) >= ?", floor)
149
+ end
150
+ end
151
+
152
+ def apply_tag_filter(scoped, key, value, tag)
153
+ return scoped unless ActiveModel::Type::Boolean.new.cast(value)
154
+
155
+ if postgres?(scoped)
156
+ scoped.where("metadata->'#{key}' @> ?", [ tag ].to_json)
157
+ else
158
+ scoped.where(
159
+ "EXISTS (SELECT 1 FROM json_each(rails_contact_contacts.metadata, '$.#{key}') WHERE json_each.value = ?)",
160
+ tag
161
+ )
162
+ end
163
+ end
164
+
165
+ # Hide rows whose metadata key equals the configured value. Built
166
+ # for default-on filters (\"hide test data\"): a nil param arrives
167
+ # here only when the config says default: :on, and nil casts to
168
+ # nil (not false), so the exclusion applies; an explicit "0"
169
+ # switches it off. Rows MISSING the key must pass — unclassified
170
+ # legacy data is not test data.
171
+ def apply_exclude_filter(scoped, key, value, excluded)
172
+ return scoped if value.present? && ActiveModel::Type::Boolean.new.cast(value) == false
173
+
174
+ if postgres?(scoped)
175
+ scoped.where("metadata->>'#{key}' IS DISTINCT FROM ?", excluded.to_s)
176
+ else
177
+ # SQLite (test harness): IS DISTINCT FROM needs 3.39+.
178
+ scoped.where("COALESCE(metadata->>'#{key}', '') <> ?", excluded.to_s)
179
+ end
180
+ end
181
+
182
+ # Descending sort over a numeric metadata key; rows with a
183
+ # non-numeric or missing value sink to the bottom, recency breaks
184
+ # ties. Only sorts declared in Configuration#metadata_sorts apply —
185
+ # an unknown ?sort= value is ignored.
186
+ def apply_metadata_sort(scoped, sort_param)
187
+ sort = Rails::Contact.configuration.metadata_sorts[sort_param.to_s]
188
+ return scoped unless sort
189
+
190
+ key = metadata_key!(sort.fetch(:key))
191
+ order = if postgres?(scoped)
192
+ "CASE WHEN metadata->>'#{key}' ~ '^[0-9]+(\\.[0-9]+){0,1}$' " \
193
+ "THEN (metadata->>'#{key}')::numeric ELSE -1 END DESC, " \
194
+ "rails_contact_contacts.created_at DESC"
195
+ else
196
+ "CAST(metadata->>'#{key}' AS REAL) DESC, rails_contact_contacts.created_at DESC"
197
+ end
198
+ scoped.reorder(Arel.sql(order))
199
+ end
200
+
201
+ def metadata_key!(key)
202
+ key = key.to_s
203
+ unless METADATA_KEY_FORMAT.match?(key)
204
+ raise ArgumentError, "metadata filter key #{key.inspect} must match #{METADATA_KEY_FORMAT.inspect}"
205
+ end
206
+
207
+ key
208
+ end
209
+
210
+ def postgres?(scoped)
211
+ scoped.klass.connection.adapter_name.match?(/postgres/i)
212
+ end
67
213
  end
68
214
  end
69
215
  end
@@ -120,8 +120,8 @@ module Rails
120
120
 
121
121
  def filter_clauses(filters)
122
122
  clauses = []
123
- clauses << { term: { current_city: filters["city"] } } if filters["city"].present?
124
- clauses << { term: { region_name: filters["region"] } } if filters["region"].present?
123
+ clauses << match_clause(:current_city, filters["city"]) if filters["city"].present?
124
+ clauses << match_clause(:region_name, filters["region"]) if filters["region"].present?
125
125
  unless filters["starred"].nil?
126
126
  starred_value = ActiveModel::Type::Boolean.new.cast(filters["starred"])
127
127
  clauses << { term: { starred: starred_value } }
@@ -133,6 +133,12 @@ module Rails
133
133
  clauses
134
134
  end
135
135
 
136
+ # city and region are multi-select filters: an array uses a `terms`
137
+ # clause (match any), a single value keeps the scalar `term` clause.
138
+ def match_clause(field, value)
139
+ value.is_a?(Array) ? { terms: { field => value } } : { term: { field => value } }
140
+ end
141
+
136
142
  def document_for(contact)
137
143
  {
138
144
  id: contact.id,
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Contact
3
- VERSION = "0.1.13"
3
+ VERSION = "0.1.16"
4
4
  end
5
5
  end
@@ -19,5 +19,4 @@ namespace :rails_contact do
19
19
  Rails::Contact::GoogleSyncJob.perform_now
20
20
  puts "Google sync completed"
21
21
  end
22
-
23
22
  end
@@ -4,10 +4,67 @@ RSpec.describe Rails::Contact::ContactsController do
4
4
  let(:controller) { described_class.new }
5
5
 
6
6
  describe "private filter params" do
7
- it "permits city/region/sync_eligible keys" do
7
+ it "permits city/sync_eligible and coerces a scalar region to an array" do
8
8
  controller.params = ActionController::Parameters.new(city: "Delhi", region: "Europe", sync_eligible: "true", x: "1")
9
9
  permitted = controller.send(:filter_params)
10
- expect(permitted.to_h).to eq({ "city" => "Delhi", "region" => "Europe", "sync_eligible" => "true" })
10
+ expect(permitted.to_h).to eq({ "city" => "Delhi", "region" => [ "Europe" ], "sync_eligible" => "true" })
11
+ end
12
+
13
+ it "permits multi-select region[] and csv_import_id[] arrays" do
14
+ controller.params = ActionController::Parameters.new(region: [ "Europe", "Asia" ], csv_import_id: [ "5", "7" ])
15
+ permitted = controller.send(:filter_params)
16
+ expect(permitted.to_h).to eq({ "region" => [ "Europe", "Asia" ], "csv_import_id" => [ "5", "7" ] })
17
+ end
18
+
19
+ it "strips the hidden blank a <select multiple> submits" do
20
+ controller.params = ActionController::Parameters.new(csv_import_id: [ "", "7" ])
21
+ permitted = controller.send(:filter_params)
22
+ expect(permitted.to_h).to eq({ "csv_import_id" => [ "7" ] })
23
+ end
24
+
25
+ it "drops a multi-select key entirely when only blanks are submitted" do
26
+ controller.params = ActionController::Parameters.new(region: [ "" ])
27
+ permitted = controller.send(:filter_params)
28
+ expect(permitted.to_h).to eq({})
29
+ end
30
+ end
31
+
32
+ describe "configured metadata filter params" do
33
+ around do |example|
34
+ config = Rails::Contact.configuration
35
+ original_filters = config.metadata_filters
36
+ original_sorts = config.metadata_sorts
37
+ config.metadata_filters = {
38
+ "tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm] },
39
+ "min_pax" => { key: "pax", type: :min_integer }
40
+ }
41
+ config.metadata_sorts = { "score" => { key: "score" } }
42
+ example.run
43
+ ensure
44
+ config.metadata_filters = original_filters
45
+ config.metadata_sorts = original_sorts
46
+ end
47
+
48
+ it "permits configured array, scalar and sort params" do
49
+ controller.params = ActionController::Parameters.new(
50
+ tier: [ "hot" ], min_pax: "4", sort: "score", unrelated: "x"
51
+ )
52
+ permitted = controller.send(:filter_params)
53
+ expect(permitted.to_h).to eq({ "tier" => [ "hot" ], "min_pax" => "4", "sort" => "score" })
54
+ end
55
+
56
+ it "normalizes configured multi-selects like region (scalar coercion + blank strip)" do
57
+ controller.params = ActionController::Parameters.new(tier: "warm")
58
+ expect(controller.send(:filter_params).to_h).to eq({ "tier" => [ "warm" ] })
59
+
60
+ controller.params = ActionController::Parameters.new(tier: [ "" ])
61
+ expect(controller.send(:filter_params).to_h).to eq({})
62
+ end
63
+
64
+ it "does not permit sort when no metadata sorts are configured" do
65
+ Rails::Contact.configuration.metadata_sorts = {}
66
+ controller.params = ActionController::Parameters.new(sort: "score")
67
+ expect(controller.send(:filter_params).to_h).to eq({})
11
68
  end
12
69
  end
13
70
 
@@ -0,0 +1,49 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Rails::Contact::Search::Backends::Database do
4
+ # metadata is a text column read back through JSON.parse, so store JSON for
5
+ # the SQL `metadata->>'csv_import_id'` extraction to resolve.
6
+ def make(name, import_id)
7
+ create(:rails_contact_contact, given_name: name, metadata: { "csv_import_id" => import_id }.to_json)
8
+ end
9
+
10
+ let!(:alice) { make("Alice", "imp_1") }
11
+ let!(:bob) { make("Bob", "imp_2") }
12
+ let!(:carol) { make("Carol", "imp_3") }
13
+
14
+ def records(filters)
15
+ described_class.new.search("", filters, page: 1, per_page: 25).records
16
+ end
17
+
18
+ describe "csv_import_id filter (multi-select)" do
19
+ it "matches contacts across every selected import (array -> IN)" do
20
+ expect(records("csv_import_id" => [ "imp_1", "imp_2" ])).to match_array([ alice, bob ])
21
+ end
22
+
23
+ it "matches a single import exactly as before (scalar)" do
24
+ expect(records("csv_import_id" => "imp_1")).to match_array([ alice ])
25
+ end
26
+
27
+ it "applies no constraint when the selection is blank only" do
28
+ expect(records("csv_import_id" => [ "" ])).to match_array([ alice, bob, carol ])
29
+ end
30
+ end
31
+
32
+ describe "query sanitization" do
33
+ def search_for(query)
34
+ described_class.new.search(query, {}, page: 1, per_page: 25).records
35
+ end
36
+
37
+ it "treats % as a literal, not a match-everything wildcard" do
38
+ expect(search_for("%")).to be_empty
39
+ end
40
+
41
+ it "still matches a real substring" do
42
+ expect(search_for("Ali")).to include(alice)
43
+ end
44
+
45
+ it "does not blow up on pathologically long input" do
46
+ expect { search_for("a" * 1000) }.not_to raise_error
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Rails::Contact::Search::Backends::Elasticsearch do
4
+ # Captures the query body so we can assert filter-clause shape without a real
5
+ # Elasticsearch server. Returns an empty hit set so #search resolves cleanly.
6
+ let(:captured) { {} }
7
+ let(:client) do
8
+ cap = captured
9
+ Class.new do
10
+ define_method(:initialize) { cap }
11
+ define_method(:search) do |index:, body:|
12
+ cap[:index] = index
13
+ cap[:body] = body
14
+ { "hits" => { "total" => { "value" => 0 }, "hits" => [] } }
15
+ end
16
+ end.new
17
+ end
18
+
19
+ def filter_clauses(filters)
20
+ described_class.new(client: client).search("", filters, page: 1, per_page: 25)
21
+ captured[:body][:query][:bool][:filter]
22
+ end
23
+
24
+ it "emits a terms clause for a multi-select region array" do
25
+ expect(filter_clauses("region" => [ "Europe", "Asia" ]))
26
+ .to include({ terms: { region_name: [ "Europe", "Asia" ] } })
27
+ end
28
+
29
+ it "emits a scalar term clause for a single city" do
30
+ expect(filter_clauses("city" => "Delhi"))
31
+ .to include({ term: { current_city: "Delhi" } })
32
+ end
33
+ end
@@ -0,0 +1,140 @@
1
+ require "rails_helper"
2
+
3
+ # Host-configured metadata filters + sorts (Configuration#metadata_filters /
4
+ # #metadata_sorts) applied by the database backend. The suite runs on SQLite;
5
+ # the PostgreSQL branches of the SQL are exercised by host applications.
6
+ RSpec.describe Rails::Contact::Search::Backends::Database do
7
+ around do |example|
8
+ config = Rails::Contact.configuration
9
+ original_filters = config.metadata_filters
10
+ original_sorts = config.metadata_sorts
11
+ config.metadata_filters = {
12
+ "tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm standard] },
13
+ "window" => { key: "booking_window", type: :values },
14
+ "min_pax" => { key: "pax", type: :min_integer },
15
+ "min_score" => { key: "score", type: :min_numeric },
16
+ "vip" => { key: "tags", type: :tag, tag: "vip" },
17
+ "hide_junk" => { key: "authenticity", type: :exclude, value: "junk", default: :on }
18
+ }
19
+ config.metadata_sorts = { "score" => { key: "score" } }
20
+ example.run
21
+ ensure
22
+ config.metadata_filters = original_filters
23
+ config.metadata_sorts = original_sorts
24
+ end
25
+
26
+ def make(name, meta)
27
+ create(:rails_contact_contact, given_name: name, metadata: meta.to_json)
28
+ end
29
+
30
+ let!(:hot) do
31
+ make("Hot", "quality_tier" => "hot", "pax" => "12", "score" => "18.6",
32
+ "booking_window" => "This week", "tags" => [ "vip" ])
33
+ end
34
+ let!(:warm) { make("Warm", "quality_tier" => "warm", "pax" => "2", "score" => "9.1") }
35
+ let!(:junk) { make("Junk", "quality_tier" => "standard", "pax" => "TBD", "score" => "n/a") }
36
+
37
+ def records(filters)
38
+ described_class.new.search("", filters, page: 1, per_page: 25).records
39
+ end
40
+
41
+ describe ":values filters" do
42
+ it "matches any of the selected values" do
43
+ expect(records("tier" => [ "hot", "warm" ])).to match_array([ hot, warm ])
44
+ end
45
+
46
+ it "coerces a scalar like the other multi-selects" do
47
+ expect(records("tier" => "hot")).to eq([ hot ])
48
+ end
49
+
50
+ it "discards values outside the whitelist" do
51
+ expect(records("tier" => [ "hot", "'; DROP TABLE--" ])).to eq([ hot ])
52
+ end
53
+
54
+ it "applies no constraint when only junk was submitted" do
55
+ expect(records("tier" => [ "'; DROP TABLE--" ])).to match_array([ hot, warm, junk ])
56
+ end
57
+
58
+ it "matches free values when no whitelist is configured" do
59
+ expect(records("window" => [ "This week" ])).to eq([ hot ])
60
+ end
61
+ end
62
+
63
+ describe ":min_integer / :min_numeric filters" do
64
+ it "keeps rows at or above the floor and drops non-numeric values" do
65
+ expect(records("min_pax" => "3")).to eq([ hot ])
66
+ expect(records("min_pax" => "2")).to match_array([ hot, warm ])
67
+ end
68
+
69
+ it "accepts decimal floors" do
70
+ expect(records("min_score" => "9.1")).to match_array([ hot, warm ])
71
+ expect(records("min_score" => "10")).to eq([ hot ])
72
+ end
73
+
74
+ it "ignores a non-positive floor" do
75
+ expect(records("min_pax" => "0")).to match_array([ hot, warm, junk ])
76
+ expect(records("min_pax" => "abc")).to match_array([ hot, warm, junk ])
77
+ end
78
+ end
79
+
80
+ describe ":tag filter" do
81
+ it "matches contacts whose tag array contains the configured tag" do
82
+ expect(records("vip" => "1")).to eq([ hot ])
83
+ end
84
+
85
+ it "is inert when the checkbox is off" do
86
+ expect(records("vip" => "0")).to match_array([ hot, warm, junk ])
87
+ end
88
+ end
89
+
90
+ describe ":exclude filter (default on)" do
91
+ let!(:junk) { make("Junk2", "authenticity" => "junk") }
92
+
93
+ it "hides the excluded value when the param is ABSENT (default on)" do
94
+ expect(records({})).not_to include(junk)
95
+ end
96
+
97
+ it "hides when the param is affirmative" do
98
+ expect(records("hide_junk" => "1")).not_to include(junk)
99
+ end
100
+
101
+ it "shows everything when explicitly disabled with 0" do
102
+ expect(records("hide_junk" => "0")).to include(junk)
103
+ end
104
+
105
+ it "never hides rows that simply lack the key (unclassified data)" do
106
+ expect(records({})).to include(hot, warm)
107
+ end
108
+ end
109
+
110
+ describe "metadata sort" do
111
+ it "orders by the metadata number descending, junk last" do
112
+ expect(records("sort" => "score")).to eq([ hot, warm, junk ])
113
+ end
114
+
115
+ it "ignores an unknown sort value" do
116
+ expect { records("sort" => "bogus") }.not_to raise_error
117
+ end
118
+
119
+ it "drops the sort under a free-text query instead of erroring (DISTINCT branch)" do
120
+ result = described_class.new.search("Stone", { "sort" => "score" }, page: 1, per_page: 25)
121
+ expect(result.records).to match_array([ hot, warm, junk ]) # factory family_name
122
+ end
123
+ end
124
+
125
+ describe "key validation" do
126
+ it "rejects a config key that is not a plain identifier" do
127
+ Rails::Contact.configuration.metadata_filters = {
128
+ "bad" => { key: "x'; DROP", type: :values }
129
+ }
130
+ expect { records("bad" => [ "1" ]) }.to raise_error(ArgumentError, /must match/)
131
+ end
132
+
133
+ it "rejects an unknown filter type" do
134
+ Rails::Contact.configuration.metadata_filters = {
135
+ "bad" => { key: "x", type: :wat }
136
+ }
137
+ expect { records("bad" => "1") }.to raise_error(ArgumentError, /unknown metadata filter type/)
138
+ end
139
+ end
140
+ end