diff --git a/CHANGELOG.md b/CHANGELOG.md index aea8420..bc587ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,91 @@ ## parse-stack-next Changelog +### 5.7.2 + +#### `between` accepts Ruby Range values + +- **NEW**: The `between` constraint now accepts a Ruby `Range` in addition to + a 2-element array, so `Person.where(:age.between => 5..25)` and + `Record.where(:date.between => 5.days.ago...2.days.ago)` work directly. An + inclusive range (`..`) maps its upper bound to `$lte`, matching the existing + array form, while an exclusive range (`...`) maps it to `$lt` instead. + Beginless (`..25`) and endless (`5..`) ranges are also supported and + constrain only the side that is present, so `Person.where(:age.between => + 18..)` compiles to `{"$gte" => 18}` with no upper bound. The array form is + unchanged, and both forms produce identical output for the same bounds. + +#### `Query#where_not_between` for the negated form of a range + +- **NEW**: `Query#where_not_between(field, value)` adds the logical negation + of `between`: `Person.query.where_not_between(:age, 5..25)` compiles to + `age < 5 OR age > 25`, accepting the same Range and 2-element Array forms + as `between` (exclusive ranges flip the upper side to `$gte`, and a + beginless or endless Range negates to a single one-sided comparison with + no `$or` needed). It is not available as a `field.not_between => value` + symbol constraint: a range's negation is inherently an `$or` of two + comparisons, and only one `$or` group can be safely merged into a + compiled query, so a symbol constraint that unilaterally emitted one + could silently collide with an existing `$or` from `or_where`/`|`. + `where_not_between` instead composes the negation the way + `Parse::Query.and` already does, so it correctly nests inside a query's + other `.where` conditions instead of replacing them, and raises + `ArgumentError` if the query already has an `$or` group rather than + silently dropping part of it. + +#### `embed_image` forwards a presigned URL when the source file has one + +- **FIXED**: `embed_image` always sent the source file's bare `file.url` to + the embedding provider (or to the SDK's own `:bytes`-mode downloader). On a + private-bucket file adapter (S3/GCS configured with `presignedUrl: true`), + `file.url` is the canonical URL with its signature stripped, so the + provider's fetch (or the SDK's download) got a 403 instead of the image. + `Parse::File` already captures the signed variant in `file.presigned_url` + whenever Parse Server returns one, but `embed_image` never read it. + Recompute now forwards `file.presigned_url` when it is present and not yet + expired, and falls back to the bare URL otherwise, for both `source: :url` + and `source: :bytes`. The stored digest is still keyed on the bare + canonical URL, so a save that only rotates the file's signature does not + trigger a needless re-embed. The validity check ignores + `presigned_url_valid?`'s default 60-second safety buffer (meant for a + browser render, not an immediate server-side fetch), since on a + private-bucket adapter the fallback URL is not fetchable at all and would + otherwise 403 for the last minute of every signature's life. + `Parse::Embeddings::ImageFetch::FetchedImage#url` now stores the + query-stripped URL rather than the presigned one, since a live signature + has no reason to survive into that value object's `#inspect` output. + `source: :url` mode can now forward a presigned URL to the embedding + provider under the same `Parse::Embeddings.trust_provider_url_fetch` + consent already required to forward any URL; operators relying on private + buckets should confirm the provider's egress handling covers + credential-bearing URLs, not just public ones. + +#### `Query#get` now resolves aliased `parse_class` names correctly + +- **FIXED**: `Query#get` looked up the target class with a raw + `Object.const_get(@table)`, which only worked when the Parse class name + matched the Ruby constant name exactly. A model that renames its table via + `parse_class "SomeOtherName"` was never found by this lookup, so `get` + silently fell back to a generic `Parse::Object`/`Parse::Pointer` instead of + hydrating the declared model. `Query#get` now passes the table name through + to `Parse::Object.build` as a string, letting it run its own + `Parse::Model.find_class` resolution, which already understands + `parse_class` aliasing. + +#### `_safe_warn` now writes through a configured logger + +- **FIXED**: Internal warnings for authentication, timeout, and cloud-code + errors (`Parse::Client._safe_warn`) always wrote to STDERR, even when an + app had configured `Parse.logger = Rails.logger` (or any other logger) for + the rest of its Parse request/response logging. These warnings now route + through `Parse::Middleware::Logging.logger` when one is configured, so they + land in the same place as the app's other logs; STDERR remains the fallback + when no logger is configured, matching prior behavior. Every call site + raises the corresponding typed `Parse::Error` immediately after this + warning, so a configured logger that itself raises (a closed handle, a + full disk, a remote-aggregator client erroring on a socket) now falls back + to STDERR rather than propagating in place of the real error and masking + it. + ### 5.7.1 #### Cache-invalidation webhooks no longer break every application hook for the same trigger diff --git a/Gemfile.lock b/Gemfile.lock index 2c82b08..5ada037 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - parse-stack-next (5.7.1) + parse-stack-next (5.7.2) activemodel (>= 6.1, < 9) activesupport (>= 6.1, < 9) connection_pool (>= 2.2, < 4) diff --git a/lib/parse/agent.rb b/lib/parse/agent.rb index c66d775..a176126 100644 --- a/lib/parse/agent.rb +++ b/lib/parse/agent.rb @@ -2475,7 +2475,6 @@ def execute(tool_name, **kwargs) end ActiveSupport::Notifications.instrument("parse.agent.tool_call", payload) do - response = nil # Install a fresh embedding accumulator for this tool span. The # process-wide "parse.embeddings.embed" subscriber records each # embed into it; the ensure below reads + restores it so the diff --git a/lib/parse/agent/mcp_dispatcher.rb b/lib/parse/agent/mcp_dispatcher.rb index b5a0a2e..38480bf 100644 --- a/lib/parse/agent/mcp_dispatcher.rb +++ b/lib/parse/agent/mcp_dispatcher.rb @@ -191,7 +191,7 @@ def self.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_t result_hash = dispatch(method, params, agent, id, logger, subscription_manager) { status: result_hash[:status], body: result_hash[:body] } - rescue Parse::Agent::Unauthorized => e + rescue Parse::Agent::Unauthorized { status: 401, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32001, "Unauthorized") } rescue StandardError => e # Do not leak the exception class name (gem fingerprinting). Server- @@ -295,7 +295,7 @@ def self.dispatch(method, params, agent, id, logger = nil, subscription_manager else { status: 200, body: jsonrpc_envelope(id, result: result) } end - rescue Parse::Agent::Unauthorized => e + rescue Parse::Agent::Unauthorized { status: 401, body: jsonrpc_error(id, -32001, "Unauthorized") } rescue Parse::Agent::AccessDenied # Class-authorization denial (agent_hidden / classes: allowlist), e.g. diff --git a/lib/parse/client.rb b/lib/parse/client.rb index 9aa77ad..0107d5e 100644 --- a/lib/parse/client.rb +++ b/lib/parse/client.rb @@ -505,23 +505,44 @@ def setup(opts = {}, &block) end # @!visibility private - # Emit a redacted warning about a Parse::Response error to stderr. + # Emit a redacted warning about a Parse::Response error. # # Routes the response error string through # {Parse::Middleware::BodyBuilder.redact} to strip credentials (passwords, # tokens, sessionTokens, access_tokens, authData) before logging, and # truncates to {SAFE_WARN_MAX_ERROR_LENGTH} chars. # + # Writes through {Parse::Middleware::Logging.logger} when the app has + # configured one (`Parse.logger = ...`), so these warnings land wherever + # the rest of the app's Parse request/response logging goes instead of + # bypassing it. Falls back to plain `warn` (STDERR) when no logger is + # configured, matching prior behavior. Every call site immediately + # raises the corresponding typed {Parse::Error} right after calling + # this method, so a misbehaving app-supplied logger (closed handle, + # full disk, a remote-aggregator client that raises on socket error) + # must not be allowed to propagate in its place and mask the real + # error — falls back to `warn` if the logger itself raises. + # # @param tag [String] the bracketed prefix (e.g. "AuthenticationError"). # @param response [Parse::Response] the response carrying the error. # @param name [String, nil] optional cloud-function or job name for context. # @return [nil] def _safe_warn(tag, response, name: nil) err = Parse::Middleware::BodyBuilder.redact(response.error.to_s)[0, SAFE_WARN_MAX_ERROR_LENGTH] - if name - warn "[Parse:#{tag}] `#{name}` [#{response.code}] #{err} (HTTP #{response.http_status})" + msg = if name + "[Parse:#{tag}] `#{name}` [#{response.code}] #{err} (HTTP #{response.http_status})" + else + "[Parse:#{tag}] [E-#{response.code}] #{response.request} : #{err} (#{response.http_status})" + end + logger = Parse::Middleware::Logging.logger + if logger + begin + logger.warn(msg) + rescue StandardError + warn msg + end else - warn "[Parse:#{tag}] [E-#{response.code}] #{response.request} : #{err} (#{response.http_status})" + warn msg end nil end diff --git a/lib/parse/client/request.rb b/lib/parse/client/request.rb index ca442b4..89912eb 100644 --- a/lib/parse/client/request.rb +++ b/lib/parse/client/request.rb @@ -17,12 +17,24 @@ class Request # @!attribute [rw] body # @return [Hash] the body of this request. - # TODO: Document opts and cache options. - # @!attribute [rw] opts - # @return [Hash] a set of options for this request. + # @return [Hash] per-request options consumed by {Parse::Client#request} + # when it builds the HTTP headers for this request. Recognized keys: + # * `:cache` — `false` sends `Cache-Control: no-cache`; `:write_only` + # skips the cache read but still writes the response; a `Numeric` + # overrides the cache expiration (seconds) for this request only. + # * `:use_master_key` — `false` forces the master key off for this + # request even if the client has one configured. + # * `:session_token` — a session token to authenticate this request as + # a specific user, bypassing the client's default auth context. + # * `:idempotent` — explicitly enables/disables idempotency-header + # generation for this request, overriding the class-level defaults. + # * `:request_id` — a caller-supplied idempotency key; see + # {.enable_idempotency!}. # @!attribute [rw] cache - # @return [Boolean] + # @return [Boolean] unused by {Parse::Request} itself; retained as a + # plain accessor for callers that stash a cache handle or flag + # directly on the request object rather than through `opts[:cache]`. attr_accessor :method, :path, :body, :headers, :opts, :cache # @!visibility private diff --git a/lib/parse/embeddings.rb b/lib/parse/embeddings.rb index f1f1fa1..f075b2a 100644 --- a/lib/parse/embeddings.rb +++ b/lib/parse/embeddings.rb @@ -382,6 +382,17 @@ def max_media_bytes # `"true"`, or a non-matching String) raises # {ConfirmationRequired}. Reset to `nil` to disable. # + # For a `:file` source backed by a private-bucket adapter + # (S3/GCS with server-side presigning), the URL forwarded under + # this sentinel may be the file's presigned URL rather than its + # bare canonical one — a time-limited bearer credential for that + # object, not just a pointer to it (see {Parse::File#presigned_url} + # and `Parse::Core::EmbedManaged.embed_image`). Reviewing the + # provider's egress behavior before setting this sentinel should + # account for that: the provider (and anyone with access to its + # request logs) gains temporary read access to the object for + # however long the signature remains valid. + # # @param value [String, nil] {TRUST_PROVIDER_URL_FETCH_SENTINEL} or nil. # @raise [ConfirmationRequired] on any other value. def trust_provider_url_fetch=(value) @@ -395,10 +406,12 @@ def trust_provider_url_fetch=(value) "String #{TRUST_PROVIDER_URL_FETCH_SENTINEL.inspect}. Plain `true` and " \ "other values are refused — forwarding image URLs to a third-party " \ "provider lets that provider issue an HTTP request from its own network " \ - "with attacker-controllable host/path. Set the sentinel only after you " \ - "have configured Parse::Embeddings.allowed_image_hosts AND reviewed the " \ - "provider's documented egress behavior (DNS rebinding window, redirect " \ - "policy)." + "with attacker-controllable host/path, and for a private-bucket file may " \ + "hand it a time-limited presigned URL rather than a bare pointer. Set the " \ + "sentinel only after you have configured " \ + "Parse::Embeddings.allowed_image_hosts AND reviewed the provider's " \ + "documented egress behavior (DNS rebinding window, redirect policy, " \ + "request-log retention)." end CONFIG_MUTEX.synchronize { @trust_provider_url_fetch = value } end diff --git a/lib/parse/embeddings/image_fetch.rb b/lib/parse/embeddings/image_fetch.rb index 986fb92..d79e7e0 100644 --- a/lib/parse/embeddings/image_fetch.rb +++ b/lib/parse/embeddings/image_fetch.rb @@ -164,7 +164,15 @@ def fetch!(url, allow_insecure: false, exif_strip: true, max_bytes: nil) mime = verify!(bytes, url: canonical) bytes = strip_metadata(bytes, mime) if exif_strip - FetchedImage.new(bytes: bytes, mime_type: mime, url: canonical) + # Store the query-stripped URL, not `canonical` verbatim: when + # `url` is a presigned URL (a private-bucket file adapter), + # `canonical` carries a live signature, and FetchedImage#url is + # purely informational from here on (nothing re-fetches it). + # Keeping the signature out of the struct preserves the + # log-safety `#inspect` below was written for — third-party + # provider adapters and error reporters that capture locals + # would otherwise leak a valid bearer credential through it. + FetchedImage.new(bytes: bytes, mime_type: mime, url: Parse::File.strip_query(canonical)) end # Verify raw bytes: sniff the magic, check the allowlist, and diff --git a/lib/parse/model/core/embed_managed.rb b/lib/parse/model/core/embed_managed.rb index 9f3ca90..2fc9b48 100644 --- a/lib/parse/model/core/embed_managed.rb +++ b/lib/parse/model/core/embed_managed.rb @@ -687,7 +687,7 @@ def self.recompute_embedding!(record, directive) return if stored_digest == digest && target_present provider = Parse::Embeddings.provider(directive.provider_name) - vectors = call_provider(provider, directive, input) + vectors = call_provider(provider, directive, input, record) unless vectors.is_a?(Array) && vectors.length == 1 && vectors.first.is_a?(Array) raise Parse::Embeddings::InvalidResponseError, "Parse::Core::EmbedManaged (#{record.class}##{directive.into}): provider " \ @@ -774,16 +774,27 @@ def self.build_source_input(record, directive) # provider a {Parse::Embeddings::ImageFetch::FetchedImage}; `:url` # mode forwards the raw URL String (the provider validates and # fetches it itself). - def self.call_provider(provider, directive, input) + # + # `input` is the bare canonical URL used for the digest (see + # {.build_source_input}). It stays stable across saves, so an + # unsigned re-read of the same file location does not force a + # re-embed. + # The actual fetch/forward target prefers the file's presigned + # URL ({Parse::File#presigned_url}) when one is currently valid, + # since a private-bucket adapter's bare `file.url` is stripped of + # its signature and will not resolve for the provider or for the + # SDK's own `:bytes`-mode download. + def self.call_provider(provider, directive, input, record) if directive.image? + fetch_url = presigned_fetch_url(record, directive, input) source = if directive.bytes_mode? Parse::Embeddings::ImageFetch.fetch!( - input, + fetch_url, allow_insecure: directive.allow_insecure ? true : false, exif_strip: directive.exif_strip != false, ) else - input + fetch_url end provider.embed_image([source], input_type: directive.input_type, @@ -793,6 +804,32 @@ def self.call_provider(provider, directive, input) end end + # @!visibility private + # Resolve the URL to actually fetch/forward for an image + # directive: the source file's currently-valid presigned URL if + # it has one, otherwise the bare canonical `fallback` (the same + # string used for the digest). Never used for text directives, so + # `directive.sources.first` is always a `:file` property here. + # + # Checks validity with a zero safety buffer rather than + # {Parse::File#presigned_url_valid?}'s default 60-second one. That + # default exists so a browser has time to render before a + # presigned URL goes stale; here it would instead spend the last + # 60 seconds of a perfectly usable presigned URL falling back to + # `fallback`, which on a private-bucket adapter is not fetchable + # at all. A fetch that starts immediately after this check has no + # meaningful use for that margin, and a 403 from a URL that + # expired mid-request is strictly better than a guaranteed 403 + # from a URL known unfetchable in advance. + def self.presigned_fetch_url(record, directive, fallback) + file = record.public_send(directive.sources.first) + if file.respond_to?(:presigned_url_valid?) && file.presigned_url_valid?(buffer: 0) + file.presigned_url + else + fallback + end + end + # @!visibility private # Concatenate source-field string values. `nil` and blank entries # are skipped; remaining values are joined with a double newline. diff --git a/lib/parse/model/core/querying.rb b/lib/parse/model/core/querying.rb index b04685d..9ca5abf 100644 --- a/lib/parse/model/core/querying.rb +++ b/lib/parse/model/core/querying.rb @@ -253,7 +253,6 @@ def each(constraints = {}, &block) # same created_at date (down to the microsecond). This prevents getting the same # record in the next query request. exclusion_set = results.select { |r| r.created_at == next_cursor.created_at }.map(&:id) - results = nil cursor = next_cursor end end @@ -356,7 +355,6 @@ def first_as(token, constraints = {}) # Object.latest(:user.eq => user, limit: 5) # => 5 most recent for user # @return [Parse::Object] the most recently created object matching constraints. def latest(constraints = {}) - fetch_count = 1 if constraints.is_a?(Numeric) fetch_count = constraints.to_i constraints = {} @@ -385,7 +383,6 @@ def latest(constraints = {}) # Object.last_updated(:user.eq => user, limit: 3) # => 3 most recently updated for user # @return [Parse::Object] the most recently updated object matching constraints. def last_updated(constraints = {}) - fetch_count = 1 if constraints.is_a?(Numeric) fetch_count = constraints.to_i constraints = {} @@ -600,7 +597,6 @@ def find(*parse_ids, type: :parallel, compact: true, cache: nil, session_token: parse_ids.compact! # determines if the result back to the call site is an array or a single result as_array = parse_ids.count > 1 - results = [] # Default to write-only cache mode - find always gets fresh data # but updates cache for future cached reads. Controlled by feature flag. diff --git a/lib/parse/model/object.rb b/lib/parse/model/object.rb index 88f4b9a..b8ac4d3 100644 --- a/lib/parse/model/object.rb +++ b/lib/parse/model/object.rb @@ -1867,7 +1867,6 @@ def self.build(json, table = nil, fetched_keys: nil, nested_fetched_keys: nil) # we should do a reverse lookup on who is registered for a different class type # than their name with parse_class klass = Parse::Model.find_class className - o = nil if klass.present? # when creating objects from Parse JSON data, don't use dirty tracking since # we are considering these objects as "pristine" diff --git a/lib/parse/query.rb b/lib/parse/query.rb index 4c8beb9..b7e2eea 100644 --- a/lib/parse/query.rb +++ b/lib/parse/query.rb @@ -1113,6 +1113,77 @@ def |(other_query) copy_query end + # Add a "field is NOT between" condition — the logical negation of + # `.where(field.between => value)`: `field < min OR field > max` for a + # fully-bounded Range/Array, or a single one-sided comparison when the + # Range is beginless/endless (mirroring how {Parse::Constraint::BetweenConstraint} + # itself only constrains the side that is present). + # + # Not available as a `field.not_between => value` symbol constraint, + # unlike `.between`: a between-style range is inherently an OR of two + # comparisons, and a single {Parse::Constraint}'s `#build` can only + # safely contribute an AND'd clause to a query. A constraint that + # unilaterally emitted a top-level `$or` would collide with (and + # silently clobber, or be clobbered by) any other `$or` this query + # already produces via {#or_where} / `|` / another `where_not_between` + # call, since only one `$or` group merges correctly per query. This + # method instead composes the negation the same way {Parse::Query.and} + # does — concatenating compiled constraint arrays — which correctly + # nests the OR inside the query's existing AND'd conditions instead of + # replacing them the way {#or_where} would. + # + # @example + # Person.query.where_not_between(:age, 5..25) + # # age < 5 OR age > 25 + # + # Record.query.where(:archived => false).where_not_between(:date, 5.days.ago...2.days.ago) + # # archived == false AND (date < 5.days.ago OR date >= 2.days.ago) + # + # @param field [Symbol, String] the field to constrain. + # @param value [Range, Array] a `between`-style value: a Range (including + # beginless/endless/exclusive-end forms) or a 2-element `[min, max]` Array. + # @return [self] + # @raise [ArgumentError] if `value` isn't a Range or 2-element Array, or + # is a fully-open (`nil..nil`) Range. + def where_not_between(field, value) + field = field.to_sym + min_value, max_value, exclude_max = Parse::Constraint::BetweenConstraint.extract_bounds(value) + + if min_value.nil? && max_value.nil? + raise ArgumentError, "Query#where_not_between: Range must have a begin, an end, or both (ex. 5.., ..25, 5..25)." + end + + # A fully-bounded range needs its own `$or` group (`field < min OR + # field > max`). Only ONE `$or` group survives the plain-Hash merge + # every constraint's compiled output goes through (`constraint_reduce` + # deep-merges compiled hashes; a second top-level `$or` key silently + # overwrites the first rather than combining with it — the same + # constraint that makes `field.not_between => value` unsafe as a + # symbol constraint, see above). Fail loudly here instead of quietly + # dropping half the query if this query already has one, from + # `#or_where`, `|`, or an earlier `where_not_between` call. + if min_value && max_value && @where.any? { |c| c.is_a?(Parse::Constraint::CompoundQueryConstraint) } + raise ArgumentError, + "Query#where_not_between: this query already has an `$or` group (from `or_where`, `|`, " \ + "or a prior `where_not_between` call). Only one `$or` group can be safely merged per " \ + "query. Compose the two queries with Parse::Query.and(...) instead." + end + + negated = if min_value.nil? + Parse::Query.new(@table).where(field.public_send(exclude_max ? :gte : :gt) => max_value) + elsif max_value.nil? + Parse::Query.new(@table).where(field.lt => min_value) + else + lower = Parse::Query.new(@table).where(field.lt => min_value) + upper = Parse::Query.new(@table).where(field.public_send(exclude_max ? :gte : :gt) => max_value) + Parse::Query.or(lower, upper) + end + + @where = @where + negated.where + @results = nil + self + end + # Queries can be made using distinct, allowing you find unique values for a specified field. # For this to be performant, please remember to index your database. # @example @@ -1409,7 +1480,6 @@ def first(limit_or_constraints = 1, mongo_direct: false, **options) return first_direct(limit_or_constraints) end - fetch_count = 1 if limit_or_constraints.is_a?(Hash) conditions(limit_or_constraints) # Check if limit was set in constraints, otherwise use 1 @@ -1490,15 +1560,19 @@ def last_updated(limit = 1, **options) # @return [Parse::Object] the object with the given ID. # @raise [Parse::Error] if the object is not found. def get(object_id) - parse_class = Object.const_get(@table) if Object.const_defined?(@table) - parse_class ||= Parse::Object - response = client.fetch_object(@table, object_id) if response.error? raise Parse::Error.new(response.code, response.error) end - Parse::Object.build(response.result, parse_class) + # Pass the table name through as-is rather than pre-resolving it to + # a Class: `Object.build` does its own `Parse::Model.find_class` + # lookup against the String, which correctly honors `parse_class` + # aliasing. Resolving to a Class first and handing that back to + # `build` broke aliased lookups, since `find_class` would then + # stringify the Ruby constant name (e.g. "Musician") instead of + # matching the declared alias (e.g. "Artist"). + Parse::Object.build(response.result, @table) end # max_results is used to iterate through as many API requests as possible using diff --git a/lib/parse/query/constraints.rb b/lib/parse/query/constraints.rb index 9f9763c..e904363 100644 --- a/lib/parse/query/constraints.rb +++ b/lib/parse/query/constraints.rb @@ -1561,7 +1561,6 @@ def build # if it's a hash, then it should be {:key=>"objectId", :query=>[]} remote_field_name = @operation.operand - query = nil if @value.is_a?(Hash) res = @value.symbolize_keys remote_field_name = res[:key] || remote_field_name @@ -1613,7 +1612,6 @@ def build # if it's a hash, then it should be {:key=>"objectId", :query=>[]} remote_field_name = @operation.operand - query = nil if @value.is_a?(Hash) res = @value.symbolize_keys remote_field_name = res[:key] || remote_field_name @@ -2263,7 +2261,6 @@ class MatchesKeyInQueryConstraint < Constraint # @return [Hash] the compiled constraint. def build remote_field_name = @operation.operand - query = nil if @value.is_a?(Hash) res = @value.symbolize_keys @@ -2314,7 +2311,6 @@ class DoesNotMatchKeyInQueryConstraint < Constraint # @return [Hash] the compiled constraint. def build remote_field_name = @operation.operand - query = nil if @value.is_a?(Hash) res = @value.symbolize_keys @@ -2516,6 +2512,19 @@ def build # User.where(:name.between => ["Alice", "John"]) # # Generates: "name": { "$gte": "Alice", "$lte": "John" } # + # # A Ruby Range works the same way as a 2-element array. An inclusive + # # range (`..`) maps its end to $lte, while an exclusive range (`...`) + # # maps its end to $lt. + # User.where(:age.between => 18..65) + # # Generates: "age": { "$gte": 18, "$lte": 65 } + # + # Record.where(:date.between => 5.days.ago...2.days.ago) + # # Generates: "date": { "$gte": <5 days ago>, "$lt": <2 days ago> } + # + # # Beginless/endless ranges only constrain the side that is present. + # User.where(:age.between => 18..) + # # Generates: "age": { "$gte": 18 } + # class BetweenConstraint < Constraint # @!method between # A registered method on a symbol to create the constraint. @@ -2526,9 +2535,11 @@ class BetweenConstraint < Constraint # @return [Hash] the compiled constraint. def build + return build_range(@value) if @value.is_a?(Range) + value = formatted_value unless value.is_a?(Array) && value.length == 2 - raise ArgumentError, "#{self.class}: Value must be an array with exactly 2 elements [min_value, max_value]" + raise ArgumentError, "#{self.class}: Value must be an array with exactly 2 elements [min_value, max_value], or a Range" end min_value, max_value = value @@ -2542,6 +2553,51 @@ def build Parse::Constraint::LessThanOrEqualConstraint.key => formatted_max, } } end + + # @!visibility private + # Extract raw (unformatted) `[min_value, max_value, exclude_max]` + # bounds from a `between`-style value: a Range (`exclude_max` + # reflects `exclude_end?`) or a 2-element Array (always inclusive + # on both ends, matching {#build}'s array branch). Shared with + # {Parse::Query#where_not_between}, which needs the same bounds + # to build the negated (`$or` of the two flipped comparisons) + # form — a shape {BetweenConstraint} itself cannot safely emit + # from a single constraint's `#build` (see `where_not_between`'s + # docs for why). + # @param value [Range, Array] a `between`-style value. + # @return [Array(Object, Object, Boolean)] + # @raise [ArgumentError] if `value` isn't a Range or 2-element Array. + def self.extract_bounds(value) + if value.is_a?(Range) + [value.begin, value.end, value.exclude_end?] + elsif value.is_a?(Array) && value.length == 2 + [value[0], value[1], false] + else + raise ArgumentError, "#{name}: Value must be an array with exactly 2 elements [min_value, max_value], or a Range" + end + end + + private + + # @return [Hash] the compiled constraint for a Ruby Range value. + def build_range(range) + bounds = {} + + unless range.begin.nil? + bounds[Parse::Constraint::GreaterThanOrEqualConstraint.key] = Parse::Constraint.formatted_value(range.begin) + end + + unless range.end.nil? + upper_key = range.exclude_end? ? Parse::Constraint::LessThanConstraint.key : Parse::Constraint::LessThanOrEqualConstraint.key + bounds[upper_key] = Parse::Constraint.formatted_value(range.end) + end + + if bounds.empty? + raise ArgumentError, "#{self.class}: Range must have a begin, an end, or both (ex. 5.., ..25, 5..25)" + end + + { @operation.operand => bounds } + end end # @!visibility private diff --git a/lib/parse/stack/version.rb b/lib/parse/stack/version.rb index 735f6fb..7dc9829 100644 --- a/lib/parse/stack/version.rb +++ b/lib/parse/stack/version.rb @@ -6,6 +6,6 @@ module Parse # The Parse Server SDK for Ruby module Stack # The current version. - VERSION = "5.7.1" + VERSION = "5.7.2" end end diff --git a/lib/parse/webhooks.rb b/lib/parse/webhooks.rb index b49703e..01cdb86 100644 --- a/lib/parse/webhooks.rb +++ b/lib/parse/webhooks.rb @@ -444,7 +444,6 @@ def call_route(type, className, payload = nil) payload.instance_variable_set(:@ruby_initiated, ruby_initiated) trusted_ruby_initiated = ruby_initiated && (payload.master? == true) else - ruby_initiated = false trusted_ruby_initiated = false end diff --git a/test/lib/parse/acl_constraints_integration_test.rb b/test/lib/parse/acl_constraints_integration_test.rb index f5c4e1e..a6ca96f 100644 --- a/test/lib/parse/acl_constraints_integration_test.rb +++ b/test/lib/parse/acl_constraints_integration_test.rb @@ -455,7 +455,7 @@ def test_acl_constraints_with_arrays # Create test roles admin_role = create_test_role("Admin") editor_role = create_test_role("Editor") - viewer_role = create_test_role("Viewer") + create_test_role("Viewer") # Create documents with role-based access doc1 = create_test_document(title: "Admin Doc", content: "Admin content") diff --git a/test/lib/parse/acl_dirty_tracking_test.rb b/test/lib/parse/acl_dirty_tracking_test.rb index 5aecf8e..1f2fea4 100644 --- a/test/lib/parse/acl_dirty_tracking_test.rb +++ b/test/lib/parse/acl_dirty_tracking_test.rb @@ -174,7 +174,7 @@ def test_changes_shows_correct_before_and_after_for_in_place_modification changes = @obj.changes["acl"] refute_nil changes, "changes should include acl" - was_acl, current_acl = changes + _, current_acl = changes # NOTE: ActiveModel's `changes` hash stores references internally, so both # was_acl and current_acl point to the same mutated object. This is a known diff --git a/test/lib/parse/agent/mcp_integration_test.rb b/test/lib/parse/agent/mcp_integration_test.rb index d99e461..9aa6592 100644 --- a/test/lib/parse/agent/mcp_integration_test.rb +++ b/test/lib/parse/agent/mcp_integration_test.rb @@ -295,7 +295,7 @@ def test_block_form_factory_returning_agent_gives_200 "CONTENT_TYPE" => "application/json", "rack.input" => StringIO.new(raw), } - status, _hdrs, chunks = app.call(env) + status, _hdrs, _chunks = app.call(env) assert_equal 200, status end diff --git a/test/lib/parse/agent/mcp_streaming_test.rb b/test/lib/parse/agent/mcp_streaming_test.rb index 7dcf96a..d16b3aa 100644 --- a/test/lib/parse/agent/mcp_streaming_test.rb +++ b/test/lib/parse/agent/mcp_streaming_test.rb @@ -703,7 +703,6 @@ def test_dispatcher_thread_kill_outer_worker_recovers_and_pushes_done assert drain_thread.join(3), "drain_thread deadlocked after dispatcher_thread kill" # Collect the events and verify we got an error response (not a real one). - chunks = [] begin drain_thread.value # re-raise any exception from drain_thread rescue @@ -822,8 +821,6 @@ def test_client_disconnect_mid_stream_no_leaked_threads app = streaming_app(heartbeat_interval: 0.1) _status, _headers, body = app.call(rack_env(accept: "text/event-stream")) - threads_before = Thread.list.size - # Partially drain (receive one event) then close — simulates client disconnect. received = [] drain_thread = Thread.new do @@ -1182,7 +1179,6 @@ def test_tool_progress_uses_request_progress_token def test_progress_callback_exceptions_do_not_break_stream # First call raises inside the callback boundary; second is well-formed. # The stream should still deliver the second event and the response. - raising_call_done = false StreamingDispatcherStub.progress_calls = [ { progress: "not-numeric" }, # invalid kwarg — but the callback itself # accepts anything; the stream encoder @@ -1207,7 +1203,6 @@ def test_progress_callback_exceptions_do_not_break_stream # --------------------------------------------------------------------------- def test_cancellation_token_is_installed_on_agent_during_dispatch - captured = nil # Capture the token the dispatcher receives so we can verify # MCPRackApp constructed and passed one along. StreamingDispatcherStub.delay = 0.05 diff --git a/test/lib/parse/agent/tools_get_objects_test.rb b/test/lib/parse/agent/tools_get_objects_test.rb index 1117b5a..c5b0273 100644 --- a/test/lib/parse/agent/tools_get_objects_test.rb +++ b/test/lib/parse/agent/tools_get_objects_test.rb @@ -57,7 +57,6 @@ def test_empty_ids_returns_empty_result_without_querying def test_50_ids_success ids = (1..50).map { |i| "id#{i.to_s.rjust(8, "0")}"[0, 10] } - ids = ids.map.with_index { |_, i| "abcde#{i.to_s.rjust(5, "0")}"[0, 10] } # Ensure uniqueness and valid format ids = (1..50).map { |i| format("abc%07d", i) } diff --git a/test/lib/parse/array_constraints_210_integration_test.rb b/test/lib/parse/array_constraints_210_integration_test.rb index 9db8987..e68e487 100644 --- a/test/lib/parse/array_constraints_210_integration_test.rb +++ b/test/lib/parse/array_constraints_210_integration_test.rb @@ -374,7 +374,6 @@ def test_empty_or_nil_with_date_constraint now = Time.now one_day_ago = now - 86400 - two_days_ago = now - 172800 with_timeout(10, "creating test data") do # Items with empty/nil tags at different times diff --git a/test/lib/parse/audience_test.rb b/test/lib/parse/audience_test.rb index cd3d9b7..99c7aeb 100644 --- a/test/lib/parse/audience_test.rb +++ b/test/lib/parse/audience_test.rb @@ -90,7 +90,6 @@ def test_concurrent_cache_access_does_not_raise def test_cache_fetch_with_concurrent_writes @fetch_return = nil - mutex = Mutex.new threads = 5.times.map do Thread.new do diff --git a/test/lib/parse/between_constraint_integration_test.rb b/test/lib/parse/between_constraint_integration_test.rb index a88dc4f..0ddf961 100644 --- a/test/lib/parse/between_constraint_integration_test.rb +++ b/test/lib/parse/between_constraint_integration_test.rb @@ -401,6 +401,112 @@ def test_between_constraint_with_strings end end + def test_between_constraint_with_range + skip "Docker integration tests require PARSE_TEST_USE_DOCKER=true" unless ENV["PARSE_TEST_USE_DOCKER"] == "true" + + with_parse_server do + with_timeout(15, "between constraint with Range test") do + puts "\n=== Testing Between Constraint with Ruby Range ===" + + 5.times do |i| + user = BetweenTestUser.new(name: "User #{i}", age: 20 + i * 5, score: 70 + i * 5) + assert user.save, "User #{i} should save" + end + + # Inclusive range (..) should behave like [min, max] + inclusive_users = BetweenTestUser.query + .where(:age.between => 25..35) + .order(:age.asc) + .results + + assert_equal 3, inclusive_users.length, "Should find 3 users in inclusive age range" + assert_equal [25, 30, 35], inclusive_users.map(&:age), "Should include both boundary ages" + + # Exclusive range (...) should exclude the upper bound + exclusive_users = BetweenTestUser.query + .where(:age.between => 25...35) + .order(:age.asc) + .results + + assert_equal 2, exclusive_users.length, "Should find 2 users in exclusive age range" + assert_equal [25, 30], exclusive_users.map(&:age), "Should exclude the upper boundary age" + + # Array and Range forms should produce identical results + array_users = BetweenTestUser.query + .where(:age.between => [25, 35]) + .order(:age.asc) + .results + + assert_equal inclusive_users.map(&:id), array_users.map(&:id), "Array and inclusive Range forms should match" + + puts "✅ Between constraint with Ruby Range works correctly" + end + end + end + + def test_between_constraint_with_endless_and_beginless_range + skip "Docker integration tests require PARSE_TEST_USE_DOCKER=true" unless ENV["PARSE_TEST_USE_DOCKER"] == "true" + + with_parse_server do + with_timeout(15, "between constraint with endless/beginless Range test") do + puts "\n=== Testing Between Constraint with Endless/Beginless Range ===" + + 5.times do |i| + user = BetweenTestUser.new(name: "User #{i}", age: 20 + i * 5, score: 70 + i * 5) + assert user.save, "User #{i} should save" + end + + # Endless range (5..) should only apply the lower bound + older_users = BetweenTestUser.query + .where(:age.between => 30..) + .order(:age.asc) + .results + + assert_equal 3, older_users.length, "Should find users 30 and older" + assert_equal [30, 35, 40], older_users.map(&:age) + + # Beginless range (..25) should only apply the upper bound + younger_users = BetweenTestUser.query + .where(:age.between => ..25) + .order(:age.asc) + .results + + assert_equal 2, younger_users.length, "Should find users 25 and younger" + assert_equal [20, 25], younger_users.map(&:age) + + puts "✅ Between constraint with endless/beginless Range works correctly" + end + end + end + + def test_between_constraint_with_time_range + skip "Docker integration tests require PARSE_TEST_USE_DOCKER=true" unless ENV["PARSE_TEST_USE_DOCKER"] == "true" + + with_parse_server do + with_timeout(15, "between constraint with Time Range test") do + puts "\n=== Testing Between Constraint with Time Range ===" + + old_user = BetweenTestUser.new(name: "Old User", age: 45, join_date: Date.parse("2020-01-15"), score: 100) + assert old_user.save, "Old user should save" + + recent_user = BetweenTestUser.new(name: "Recent User", age: 28, join_date: Date.parse("2023-06-10"), score: 85) + assert recent_user.save, "Recent user should save" + + new_user = BetweenTestUser.new(name: "New User", age: 26, join_date: Date.parse("2024-08-01"), score: 75) + assert new_user.save, "New user should save" + + users_2023 = BetweenTestUser.query + .where(:join_date.between => Date.parse("2023-01-01")..Date.parse("2023-12-31")) + .results + + assert_equal 1, users_2023.length, "Should find 1 user who joined in 2023" + assert_equal "Recent User", users_2023.first.name + + puts "✅ Between constraint with Time Range works correctly" + end + end + end + def test_between_constraint_string_vs_manual_comparison skip "Docker integration tests require PARSE_TEST_USE_DOCKER=true" unless ENV["PARSE_TEST_USE_DOCKER"] == "true" diff --git a/test/lib/parse/cache_integration_test.rb b/test/lib/parse/cache_integration_test.rb index 531cc2d..2b394e4 100644 --- a/test/lib/parse/cache_integration_test.rb +++ b/test/lib/parse/cache_integration_test.rb @@ -185,7 +185,6 @@ def test_cache_expiration_behavior # Fetch with custom cache expires header # Note: This tests the X-Parse-Stack-Cache-Expires header functionality - client = Parse.client # First fetch should populate cache fetched_product1 = CacheTestProduct.find(product_id) diff --git a/test/lib/parse/cache_write_only_test.rb b/test/lib/parse/cache_write_only_test.rb index 2b85c6e..b5bb0e1 100644 --- a/test/lib/parse/cache_write_only_test.rb +++ b/test/lib/parse/cache_write_only_test.rb @@ -173,7 +173,6 @@ def test_fetch_defaults_to_write_only_when_feature_enabled # Mock the client to capture the request captured_opts = nil - original_client = song.method(:client) song.define_singleton_method(:client) do mock_client = Object.new mock_client.define_singleton_method(:fetch_object) do |klass, id, **opts| @@ -428,7 +427,6 @@ def test_find_with_explicit_cache_false # Create a mock client to capture requests captured_cache_value = nil - original_client = Parse.client mock_client = Object.new mock_client.define_singleton_method(:fetch_object) do |klass, id, **opts| @@ -517,7 +515,7 @@ def test_write_only_mode_skips_cache_read } # Call middleware - response = middleware.call(env) + middleware.call(env) # Should have called the app (not used cache) assert fresh_response_called, "Should call the app when write_only mode is enabled" diff --git a/test/lib/parse/client/safe_warn_test.rb b/test/lib/parse/client/safe_warn_test.rb index 490a904..3ccbc66 100644 --- a/test/lib/parse/client/safe_warn_test.rb +++ b/test/lib/parse/client/safe_warn_test.rb @@ -98,4 +98,67 @@ def test_returns_nil assert_nil Parse::Client._safe_warn("ServerError", r) end end + + # ---- logger routing (5.7.2) ------------------------------------------- + + def teardown + Parse::Middleware::Logging.logger = nil + end + + def test_routes_through_configured_logger_instead_of_stderr + messages = [] + fake_logger = Object.new + fake_logger.define_singleton_method(:warn) { |msg| messages << msg } + Parse::Middleware::Logging.logger = fake_logger + + r = make_response(error: "Boom") + _out, err = capture_io do + Parse::Client._safe_warn("ServerError", r) + end + + assert_empty err, "must not also write to STDERR when a logger is configured" + assert_equal 1, messages.length + assert_match(/\[Parse:ServerError\]/, messages.first) + assert_match(/Boom/, messages.first) + end + + def test_falls_back_to_stderr_when_no_logger_configured + Parse::Middleware::Logging.logger = nil + r = make_response(error: "Boom") + + _out, err = capture_io do + Parse::Client._safe_warn("ServerError", r) + end + + assert_match(/\[Parse:ServerError\]/, err) + end + + def test_falls_back_to_stderr_when_configured_logger_raises + broken_logger = Object.new + broken_logger.define_singleton_method(:warn) { |_msg| raise IOError, "closed stream" } + Parse::Middleware::Logging.logger = broken_logger + + r = make_response(error: "Boom") + result = nil + _out, err = capture_io do + result = Parse::Client._safe_warn("ServerError", r) + end + + assert_nil result, "_safe_warn must not let a raising logger propagate past it" + assert_match(/\[Parse:ServerError\]/, err, + "a raising logger must fall back to STDERR instead of masking the real error") + end + + def test_logger_path_still_redacts_credentials + messages = [] + fake_logger = Object.new + fake_logger.define_singleton_method(:warn) { |msg| messages << msg } + Parse::Middleware::Logging.logger = fake_logger + + r = make_response(error: 'failed: password="hunter2"') + capture_io { Parse::Client._safe_warn("ServerError", r) } + + refute_match(/hunter2/, messages.first) + assert_match(/\[FILTERED\]/, messages.first) + end end diff --git a/test/lib/parse/client_rest_files_integration_test.rb b/test/lib/parse/client_rest_files_integration_test.rb index cd4fe5c..e7a9cb7 100644 --- a/test/lib/parse/client_rest_files_integration_test.rb +++ b/test/lib/parse/client_rest_files_integration_test.rb @@ -73,7 +73,7 @@ def test_anonymous_upload_does_not_use_master_key filename = "anon_#{SecureRandom.hex(3)}.txt" begin - response = Parse.client.create_file(filename, contents, "text/plain") + Parse.client.create_file(filename, contents, "text/plain") # If it succeeded, that's purely because Parse Server's # fileUpload.anonymousUsers was on. We can't disprove that from # the SDK side — but we CAN confirm the master key wasn't sent diff --git a/test/lib/parse/cloud_config_integration_test.rb b/test/lib/parse/cloud_config_integration_test.rb index 7080cf0..da701eb 100644 --- a/test/lib/parse/cloud_config_integration_test.rb +++ b/test/lib/parse/cloud_config_integration_test.rb @@ -434,7 +434,7 @@ def test_config_client_methods_and_caching Parse.set_config("cacheTest", "updatedValue") # Read with caching (should still return old cached value) - cached_config3 = Parse.config + Parse.config # Note: This might still return the cached value depending on implementation # Force refresh cache diff --git a/test/lib/parse/count_distinct_integration_test.rb b/test/lib/parse/count_distinct_integration_test.rb index d0d2db6..5576892 100644 --- a/test/lib/parse/count_distinct_integration_test.rb +++ b/test/lib/parse/count_distinct_integration_test.rb @@ -110,8 +110,6 @@ def test_count_distinct_with_mixed_conditions_including_dates base_time = Time.now.utc yesterday = base_time - 86400 - week_ago = base_time - 604800 - month_ago = base_time - 2592000 # Create reviews with different dates and ratings reviews = [] diff --git a/test/lib/parse/count_distinct_simple_test.rb b/test/lib/parse/count_distinct_simple_test.rb index de917ef..faaf3bc 100644 --- a/test/lib/parse/count_distinct_simple_test.rb +++ b/test/lib/parse/count_distinct_simple_test.rb @@ -32,7 +32,6 @@ def test_count_distinct_pipeline_construction query = Parse::Query.new("Song") # Mock the client to capture the pipeline - captured_pipeline = nil mock_client = Object.new def mock_client.aggregate_pipeline(table, pipeline, **opts) @captured_pipeline = pipeline @@ -75,7 +74,6 @@ def test_count_distinct_with_where_conditions query.where(:play_count.gt => 100) # Mock the client to capture the pipeline - captured_pipeline = nil mock_client = Object.new def mock_client.aggregate_pipeline(table, pipeline, **opts) @captured_pipeline = pipeline diff --git a/test/lib/parse/count_distinct_test.rb b/test/lib/parse/count_distinct_test.rb index 7feed49..34f322f 100644 --- a/test/lib/parse/count_distinct_test.rb +++ b/test/lib/parse/count_distinct_test.rb @@ -19,11 +19,6 @@ def mock_response.respond_to?(method) [:error?, :result].include?(method) || super end - expected_pipeline = [ - { "$group" => { "_id" => "$genre" } }, - { "$count" => "distinctCount" }, - ] - @mock_client.expect :aggregate_pipeline, mock_response do |table, pipeline, **kwargs| table == "Song" && pipeline.is_a?(Array) end @@ -47,12 +42,6 @@ def mock_response.respond_to?(method) [:error?, :result].include?(method) || super end - expected_pipeline = [ - { "$match" => { "playCount" => { "$gt" => 100 } } }, - { "$group" => { "_id" => "$artist" } }, - { "$count" => "distinctCount" }, - ] - @mock_client.expect :aggregate_pipeline, mock_response do |table, pipeline, **kwargs| table == "Song" && pipeline.is_a?(Array) end @@ -73,11 +62,6 @@ def mock_response.respond_to?(method) [:error?, :result].include?(method) || super end - expected_pipeline = [ - { "$group" => { "_id" => "$genre" } }, - { "$count" => "distinctCount" }, - ] - @mock_client.expect :aggregate_pipeline, mock_response do |table, pipeline, **kwargs| table == "Song" && pipeline.is_a?(Array) end @@ -97,11 +81,6 @@ def mock_response.respond_to?(method) method == :error? || super end - expected_pipeline = [ - { "$group" => { "_id" => "$genre" } }, - { "$count" => "distinctCount" }, - ] - @mock_client.expect :aggregate_pipeline, mock_response do |table, pipeline, **kwargs| table == "Song" && pipeline.is_a?(Array) end @@ -141,11 +120,6 @@ def mock_response.respond_to?(method) end # Test that snake_case field gets converted to camelCase - expected_pipeline = [ - { "$group" => { "_id" => "$playCount" } }, - { "$count" => "distinctCount" }, - ] - @mock_client.expect :aggregate_pipeline, mock_response do |table, pipeline, **kwargs| table == "Song" && pipeline.is_a?(Array) end @@ -179,16 +153,6 @@ def mock_response.respond_to?(method) end # The pipeline should include a $match stage with all conditions - expected_match = { - "playCount" => { "$gt" => 100 }, - "genre" => "rock", - "releaseDate" => { - "$gte" => { "__type" => "Date", "iso" => yesterday.iso8601(3) }, - "$lte" => { "__type" => "Date", "iso" => now.iso8601(3) }, - }, - "featured" => true, - } - @mock_client.expect :aggregate_pipeline, mock_response do |table, pipeline, **kwargs| table == "Song" && pipeline.is_a?(Array) && diff --git a/test/lib/parse/distinct_pointer_test.rb b/test/lib/parse/distinct_pointer_test.rb index 063a39d..f9166ce 100644 --- a/test/lib/parse/distinct_pointer_test.rb +++ b/test/lib/parse/distinct_pointer_test.rb @@ -21,11 +21,6 @@ def mock_response.respond_to?(method) [:error?, :result].include?(method) || super end - expected_pipeline = [ - { "$group" => { "_id" => "$project" } }, - { "$project" => { "_id" => 0, "value" => "$_id" } }, - ] - @mock_client.expect :aggregate_pipeline, mock_response do |table, pipeline, **kwargs| table == "Asset" && pipeline.is_a?(Array) end @@ -57,11 +52,6 @@ def mock_response.respond_to?(method) [:error?, :result].include?(method) || super end - expected_pipeline = [ - { "$group" => { "_id" => "$category" } }, - { "$project" => { "_id" => 0, "value" => "$_id" } }, - ] - @mock_client.expect :aggregate_pipeline, mock_response do |table, pipeline, **kwargs| table == "Asset" && pipeline.is_a?(Array) end @@ -89,11 +79,6 @@ def mock_response.respond_to?(method) [:error?, :result].include?(method) || super end - expected_pipeline = [ - { "$group" => { "_id" => "$project" } }, - { "$project" => { "_id" => 0, "value" => "$_id" } }, - ] - @mock_client.expect :aggregate_pipeline, mock_response do |table, pipeline, **kwargs| table == "Asset" && pipeline.is_a?(Array) end @@ -160,11 +145,6 @@ def mock_response.respond_to?(method) [:error?, :result].include?(method) || super end - expected_pipeline = [ - { "$group" => { "_id" => "$name" } }, - { "$project" => { "_id" => 0, "value" => "$_id" } }, - ] - @mock_client.expect :aggregate_pipeline, mock_response do |table, pipeline, **kwargs| table == "Asset" && pipeline.is_a?(Array) end diff --git a/test/lib/parse/docker_integration_test.rb b/test/lib/parse/docker_integration_test.rb index 19b0fc9..67c7f7c 100644 --- a/test/lib/parse/docker_integration_test.rb +++ b/test/lib/parse/docker_integration_test.rb @@ -218,7 +218,7 @@ def test_parse_schema_upgrade ["Post", "Author", "Comment"].each do |class_name| begin Parse.client.delete_schema(class_name, use_master_key: true) - rescue => e + rescue # Ignore errors if schema doesn't exist end end diff --git a/test/lib/parse/embed_managed_image_test.rb b/test/lib/parse/embed_managed_image_test.rb index 6bde1bf..c622b07 100644 --- a/test/lib/parse/embed_managed_image_test.rb +++ b/test/lib/parse/embed_managed_image_test.rb @@ -201,6 +201,66 @@ def test_recompute_re_embeds_when_file_url_changes assert_equal ["https://1.1.1.1/cover_v2.jpg"], @stub.calls.last[:sources] end + def test_recompute_forwards_presigned_url_when_valid + doc = ImageDoc.new + doc.cover_art = file_with_url("https://1.1.1.1/cover.jpg") + doc.cover_art.instance_variable_set(:@presigned_url, "https://1.1.1.1/cover.jpg?X-Amz-Signature=abc") + doc.cover_art.instance_variable_set(:@presigned_url_expires_at, Time.now.utc + 3600) + + Parse::Core::EmbedManaged.recompute_embedding!(doc, directive_for(ImageDoc, :cover_embedding)) + + assert_equal 1, @stub.calls.length + assert_equal ["https://1.1.1.1/cover.jpg?X-Amz-Signature=abc"], @stub.calls.first[:sources], + "provider should receive the presigned URL, not the bare canonical file.url" + end + + def test_recompute_falls_back_to_bare_url_when_presigned_url_expired + doc = ImageDoc.new + doc.cover_art = file_with_url("https://1.1.1.1/cover.jpg") + doc.cover_art.instance_variable_set(:@presigned_url, "https://1.1.1.1/cover.jpg?X-Amz-Signature=abc") + doc.cover_art.instance_variable_set(:@presigned_url_expires_at, Time.now.utc - 1) + + Parse::Core::EmbedManaged.recompute_embedding!(doc, directive_for(ImageDoc, :cover_embedding)) + + assert_equal ["https://1.1.1.1/cover.jpg"], @stub.calls.first[:sources], + "an expired presigned URL must not be forwarded" + end + + def test_recompute_forwards_presigned_url_within_default_safety_buffer + # presigned_url_valid?'s default 60s buffer exists for browser + # rendering; it must NOT apply here. On a private-bucket adapter the + # fallback (bare file.url) is unfetchable, so treating a + # still-valid presigned URL as "expired soon" would deterministically + # 403 for the last 60 seconds of every signature's life. + doc = ImageDoc.new + doc.cover_art = file_with_url("https://1.1.1.1/cover.jpg") + doc.cover_art.instance_variable_set(:@presigned_url, "https://1.1.1.1/cover.jpg?X-Amz-Signature=abc") + doc.cover_art.instance_variable_set(:@presigned_url_expires_at, Time.now.utc + 30) + + Parse::Core::EmbedManaged.recompute_embedding!(doc, directive_for(ImageDoc, :cover_embedding)) + + assert_equal ["https://1.1.1.1/cover.jpg?X-Amz-Signature=abc"], @stub.calls.first[:sources], + "a presigned URL with 30s left must still be used, not treated as expired" + end + + def test_recompute_digest_is_stable_across_presigned_url_rotation + doc = ImageDoc.new + doc.cover_art = file_with_url("https://1.1.1.1/cover.jpg") + d = directive_for(ImageDoc, :cover_embedding) + Parse::Core::EmbedManaged.recompute_embedding!(doc, d) + digest_before = doc.cover_embedding_digest + + # Same underlying file location, but Parse Server minted a fresh + # signature (as it does on every read). The digest must not change + # since it is keyed on the bare canonical URL, not the signature. + doc.cover_art.instance_variable_set(:@presigned_url, "https://1.1.1.1/cover.jpg?X-Amz-Signature=rotated") + doc.cover_art.instance_variable_set(:@presigned_url_expires_at, Time.now.utc + 3600) + Parse::Core::EmbedManaged.recompute_embedding!(doc, d) + + assert_equal digest_before, doc.cover_embedding_digest + assert_equal 1, @stub.calls.length, "Provider must not be re-called when only the signature rotated" + end + def test_recompute_clears_vector_and_digest_when_file_is_nil doc = ImageDoc.new doc.cover_art = file_with_url("https://1.1.1.1/cover.jpg") diff --git a/test/lib/parse/embeddings_image_fetch_test.rb b/test/lib/parse/embeddings_image_fetch_test.rb index dc2c7a8..5ed8de4 100644 --- a/test/lib/parse/embeddings_image_fetch_test.rb +++ b/test/lib/parse/embeddings_image_fetch_test.rb @@ -283,6 +283,21 @@ def test_fetch_forwards_max_bytes_to_safe_open_url assert_equal 4096, seen[:max_bytes], "fetch! must forward max_bytes: into safe_open_url" end + # FetchedImage#url must never retain a query string: on a + # private-bucket adapter, `url` passed into fetch! can be a presigned + # URL, and FetchedImage's whole point (see its #inspect) is to be + # safe to interpolate into exceptions/logs without leaking bytes — + # or a live signature. + def test_fetch_strips_query_string_from_stored_url + Parse::Embeddings.allowed_image_hosts = ["1.1.1.1"] + with_stubbed_download(png_with_exif) do + img = IF.fetch!("https://1.1.1.1/photo.png?X-Amz-Signature=super-secret-token") + assert_equal "https://1.1.1.1/photo.png", img.url + refute_match(/X-Amz-Signature|super-secret-token/, img.inspect) + refute_match(/X-Amz-Signature|super-secret-token/, img.to_s) + end + end + def test_fetch_requires_host_allowlist_but_not_sentinel # No trust_provider_url_fetch sentinel set — :fetch mode must work # on allowlist alone (the SDK fetches; no provider egress). diff --git a/test/lib/parse/equals_linked_pointer_test.rb b/test/lib/parse/equals_linked_pointer_test.rb index 1ea768d..4f1e221 100644 --- a/test/lib/parse/equals_linked_pointer_test.rb +++ b/test/lib/parse/equals_linked_pointer_test.rb @@ -116,10 +116,6 @@ def test_query_requires_aggregation_pipeline_detection # Add equals_linked_pointer constraint query.where(:author.equals_linked_pointer => { through: :project, field: :owner }) - # Debug: check the compiled where clause structure - compiled_where = query.compile_where - # puts "Compiled where: #{compiled_where.inspect}" - # Now should require pipeline assert query.requires_aggregation_pipeline? end diff --git a/test/lib/parse/features_220_integration_test.rb b/test/lib/parse/features_220_integration_test.rb index d40267d..8c54550 100644 --- a/test/lib/parse/features_220_integration_test.rb +++ b/test/lib/parse/features_220_integration_test.rb @@ -160,7 +160,7 @@ def cleanup_test_models # Delete all objects of this class (limit 1000 should be enough for tests) objects = klass.all(limit: 1000) objects.each { |obj| obj.destroy rescue nil } - rescue => e + rescue # Ignore cleanup errors - class may not exist yet end end diff --git a/test/lib/parse/field_guards_delete_op_integration_test.rb b/test/lib/parse/field_guards_delete_op_integration_test.rb index c9416c2..e4dd511 100644 --- a/test/lib/parse/field_guards_delete_op_integration_test.rb +++ b/test/lib/parse/field_guards_delete_op_integration_test.rb @@ -64,11 +64,6 @@ def test_delete_op_overrides_client_supplied_value_on_create # The realistic scenario: a client sends BOTH a value and our webhook # response wants to drop it. Parse Server merges the webhook response with # the client payload, so the Delete op in the response must win. - body = { - "slug" => "create-override", - # The client tried to write this value: - "secret" => "client-tried-to-leak-this", - } # Now simulate the webhook response merging by sending a SECOND payload # that resembles what our webhook code would emit: secret gets a Delete op. # Since we can't intercept here without a Rack endpoint, this case is diff --git a/test/lib/parse/field_guards_test.rb b/test/lib/parse/field_guards_test.rb index c357c57..2cfdf5e 100644 --- a/test/lib/parse/field_guards_test.rb +++ b/test/lib/parse/field_guards_test.rb @@ -433,7 +433,7 @@ def test_class_with_only_guards_auto_registers_before_save_route # picks it up and Parse Server actually invokes our webhook. Parse::Webhooks.instance_variable_set(:@routes, nil) - klass = Class.new(Parse::Object) do + Class.new(Parse::Object) do def self.parse_class; "AutoRegisteredGuardClass"; end property :name, :string property :owner, :string @@ -603,7 +603,7 @@ def test_guard_on_non_existent_property_is_silent_noop # A guard declared for a property name that doesn't exist on the model # cannot fire because the field is never in `changed`. This is a silent # no-op rather than a class-load-time error. - klass = Class.new(Parse::Object) do + Class.new(Parse::Object) do def self.parse_class; "GuardedMissingField"; end property :real_field, :string guard :imaginary_field, :master_only # not declared as a property diff --git a/test/lib/parse/field_selection_integration_test.rb b/test/lib/parse/field_selection_integration_test.rb index a4cfd7f..eb36124 100644 --- a/test/lib/parse/field_selection_integration_test.rb +++ b/test/lib/parse/field_selection_integration_test.rb @@ -479,7 +479,6 @@ def test_select_constraint_functionality # Test select constraint with simplified syntax (when field names match) # Create a query that looks for users by author_name field (this won't work since users don't have author_name) # Instead, let's test with a working scenario where field names actually match - posts_with_specific_names = FieldSelectionPost.query.where(:title.contains => "Famous") # This simplified syntax would look for Users where 'title' field matches, but Users don't have title # So let's create a more appropriate test diff --git a/test/lib/parse/graphql_type_generator_test.rb b/test/lib/parse/graphql_type_generator_test.rb index 14a6b2f..34a03fd 100644 --- a/test/lib/parse/graphql_type_generator_test.rb +++ b/test/lib/parse/graphql_type_generator_test.rb @@ -189,7 +189,6 @@ def test_raw_array_property_emits_json_scalar_with_warning _stderr_was = $stderr captured = StringIO.new $stderr = captured - type = nil begin type = Parse::GraphQL::TypeGenerator.generate_all(MODELS)["GqlGenArtist"] ensure diff --git a/test/lib/parse/hooks_and_validation_integration_test.rb b/test/lib/parse/hooks_and_validation_integration_test.rb index 8844d64..679f38d 100644 --- a/test/lib/parse/hooks_and_validation_integration_test.rb +++ b/test/lib/parse/hooks_and_validation_integration_test.rb @@ -159,7 +159,7 @@ def shipping_date_validation def should_send_email? # Use previous_changes in after_save context if previous_changes && previous_changes[:status] - old_status, new_status = previous_changes[:status] + _, new_status = previous_changes[:status] ["completed", "shipped"].include?(new_status) else # Fallback for before_save context diff --git a/test/lib/parse/live_query_integration_test.rb b/test/lib/parse/live_query_integration_test.rb index ad18dbd..1c4f0aa 100644 --- a/test/lib/parse/live_query_integration_test.rb +++ b/test/lib/parse/live_query_integration_test.rb @@ -45,7 +45,7 @@ def cleanup_test_objects TestLiveQueryModel.all.each do |obj| obj.destroy rescue nil end - rescue => e + rescue # Ignore errors during cleanup end diff --git a/test/lib/parse/model_associations_integration_test.rb b/test/lib/parse/model_associations_integration_test.rb index ee11eb3..553d273 100644 --- a/test/lib/parse/model_associations_integration_test.rb +++ b/test/lib/parse/model_associations_integration_test.rb @@ -890,7 +890,7 @@ def test_association_edge_cases_and_error_handling # Note: Direct query works (AssociationTestBook.all(author: special_author) finds 1 book) # but has_many association query has an issue - skipping this assertion for now # TODO: Investigate why has_many association query doesn't find the book - special_books = special_author.books.results + special_author.books.results all_books_for_author = AssociationTestBook.all(author: special_author) if all_books_for_author.count > 0 diff --git a/test/lib/parse/models/count_distinct_model_test.rb b/test/lib/parse/models/count_distinct_model_test.rb index f62bd78..d32d0c7 100644 --- a/test/lib/parse/models/count_distinct_model_test.rb +++ b/test/lib/parse/models/count_distinct_model_test.rb @@ -70,8 +70,6 @@ def response.success? true end - # Capture response_data in the closure - response_data = @response_data def response.result response_data end diff --git a/test/lib/parse/models/polygon_test.rb b/test/lib/parse/models/polygon_test.rb index 7b465df..d6b08b3 100644 --- a/test/lib/parse/models/polygon_test.rb +++ b/test/lib/parse/models/polygon_test.rb @@ -109,7 +109,7 @@ def test_contains_point_invalid_arg end def test_warns_below_min_vertices - out, _err = capture_io do + _, _err = capture_io do Parse::Polygon.new [[0.0, 0.0], [1.0, 1.0]] end # Warnings go to $stderr via Kernel#warn, captured by capture_io diff --git a/test/lib/parse/models/transaction_test.rb b/test/lib/parse/models/transaction_test.rb index 3882a0a..de78e66 100644 --- a/test/lib/parse/models/transaction_test.rb +++ b/test/lib/parse/models/transaction_test.rb @@ -107,7 +107,7 @@ def test_transaction_with_custom_retry_count end begin - result = Parse::Object.transaction(retries: 10) do |batch| + Parse::Object.transaction(retries: 10) do |batch| # Test that custom retry count is accepted assert_instance_of Parse::BatchOperation, batch end diff --git a/test/lib/parse/mongodb_direct_integration_test.rb b/test/lib/parse/mongodb_direct_integration_test.rb index 23d3819..6aa43c2 100644 --- a/test/lib/parse/mongodb_direct_integration_test.rb +++ b/test/lib/parse/mongodb_direct_integration_test.rb @@ -2086,8 +2086,6 @@ def test_aggregate_group_by_pointer_direct puts " Group IDs: #{group_ids.inspect}" has_null = group_ids.include?(nil) - has_artist1 = group_ids.any? { |id| id.to_s.include?(artist1.id) } - has_artist2 = group_ids.any? { |id| id.to_s.include?(artist2.id) } assert has_null, "Should have null group for albums without artist" # Note: The group by pointer returns pointer format, so check for id presence @@ -2564,7 +2562,6 @@ def test_aggregate_group_by_date_object_direct puts "\n=== Testing Aggregate Group by Date Object ===" # Create test data with dates - today = Time.now.utc data = [ { title: "DateGroup1", artist: "DateGroup Artist", genre: "Rock", plays: 100 }, { title: "DateGroup2", artist: "DateGroup Artist", genre: "Pop", plays: 200 }, diff --git a/test/lib/parse/partial_fetch_integration_test.rb b/test/lib/parse/partial_fetch_integration_test.rb index bc27bd1..fd0ac81 100644 --- a/test/lib/parse/partial_fetch_integration_test.rb +++ b/test/lib/parse/partial_fetch_integration_test.rb @@ -900,7 +900,6 @@ def test_belongs_to_assignment_to_unfetched_field_tracks_changes author: user1, ) assert post.save, "Post should save" - post_id = post.id # Fetch with only :title (author is not fetched) fetched_post = PartialFetchPost.first(keys: [:id, :title]) diff --git a/test/lib/parse/push_integration_test.rb b/test/lib/parse/push_integration_test.rb index b8b66f4..799487f 100644 --- a/test/lib/parse/push_integration_test.rb +++ b/test/lib/parse/push_integration_test.rb @@ -207,7 +207,6 @@ def test_installation_subscribe_structure # Test that subscribe modifies channels locally installation.channels = [] - original_channels = installation.channels.to_a.dup # Mock save to prevent actual API call in this structure test installation.define_singleton_method(:save) { true } diff --git a/test/lib/parse/query/aggregation_features_test.rb b/test/lib/parse/query/aggregation_features_test.rb index adbe371..85d4ed4 100644 --- a/test/lib/parse/query/aggregation_features_test.rb +++ b/test/lib/parse/query/aggregation_features_test.rb @@ -517,8 +517,6 @@ def test_count_distinct_pipeline_with_dates aggregation_where = query.send(:convert_constraints_for_aggregation, compiled_where) puts "After constraint conversion: #{aggregation_where.inspect}" - stringified_where = query.send(:convert_dates_for_aggregation, aggregation_where) - aggregation_where = query.send(:convert_constraints_for_aggregation, compiled_where) stringified_where = query.send(:convert_dates_for_aggregation, aggregation_where) diff --git a/test/lib/parse/query/constraints/between_test.rb b/test/lib/parse/query/constraints/between_test.rb new file mode 100644 index 0000000..ed368a0 --- /dev/null +++ b/test/lib/parse/query/constraints/between_test.rb @@ -0,0 +1,95 @@ +require_relative "../../../../test_helper" + +class TestBetweenConstraint < Minitest::Test + extend Minitest::Spec::DSL + include ConstraintTests + + def setup + @klass = Parse::Constraint::BetweenConstraint + @key = nil # This constraint doesn't map to a single key + @operand = :between + @keys = [:between] + @skip_scalar_values_test = true + end + + def build(value) + if value.is_a?(Array) && value.length == 2 + min_value, max_value = value + { "field" => { + "$gte" => Parse::Constraint.formatted_value(min_value), + "$lte" => Parse::Constraint.formatted_value(max_value), + } } + else + { "field" => Parse::Constraint.formatted_value(value) } + end + end + + def test_with_numeric_array + constraint = @klass.new(:age, [5, 25]) + expected = { age: { :$gte => 5, :$lte => 25 } } + assert_equal expected, constraint.build + end + + def test_with_inclusive_range + constraint = @klass.new(:age, 5..25) + expected = { age: { :$gte => 5, :$lte => 25 } } + assert_equal expected, constraint.build + end + + def test_with_exclusive_range + constraint = @klass.new(:age, 5...25) + expected = { age: { :$gte => 5, :$lt => 25 } } + assert_equal expected, constraint.build + end + + def test_with_date_range + start_date = DateTime.new(2023, 1, 1) + end_date = DateTime.new(2023, 12, 31) + constraint = @klass.new(:created_at, start_date...end_date) + + expected_start = { __type: "Date", iso: start_date.utc.iso8601(3) } + expected_end = { __type: "Date", iso: end_date.utc.iso8601(3) } + expected = { created_at: { :$gte => expected_start, :$lt => expected_end } } + + assert_equal expected, constraint.build + end + + def test_with_beginless_range + constraint = @klass.new(:age, ..25) + expected = { age: { :$lte => 25 } } + assert_equal expected, constraint.build + end + + def test_with_endless_range + constraint = @klass.new(:age, 5..) + expected = { age: { :$gte => 5 } } + assert_equal expected, constraint.build + end + + def test_with_endless_exclusive_range + constraint = @klass.new(:age, 5...) + expected = { age: { :$gte => 5 } } + assert_equal expected, constraint.build + end + + def test_invalid_single_value_raises_error + constraint = @klass.new(:age, 25) + assert_raises(ArgumentError) do + constraint.build + end + end + + def test_invalid_one_element_array_raises_error + constraint = @klass.new(:age, [25]) + assert_raises(ArgumentError) do + constraint.build + end + end + + def test_invalid_three_element_array_raises_error + constraint = @klass.new(:age, [5, 15, 25]) + assert_raises(ArgumentError) do + constraint.build + end + end +end diff --git a/test/lib/parse/query/constraints/nullability_test.rb b/test/lib/parse/query/constraints/nullability_test.rb index d602525..3b7584f 100644 --- a/test/lib/parse/query/constraints/nullability_test.rb +++ b/test/lib/parse/query/constraints/nullability_test.rb @@ -30,7 +30,6 @@ def test_scalar_values ["true", 1, nil].each do |value| constraint = @klass.new(:field, value) assert_raises(ArgumentError) do - expected = build(value).as_json constraint.build.as_json end end diff --git a/test/lib/parse/query/constraints/polygon_test.rb b/test/lib/parse/query/constraints/polygon_test.rb index 72b562a..0811d6b 100644 --- a/test/lib/parse/query/constraints/polygon_test.rb +++ b/test/lib/parse/query/constraints/polygon_test.rb @@ -34,7 +34,6 @@ def test_argument_error end def test_compiled_query - triangle = [@bermuda, @miami, @san_juan] compiled_query = { "location" => { "$geoWithin" => { "$polygon" => [ { :__type => "GeoPoint", :latitude => 32.3078, :longitude => -64.7504999 }, { :__type => "GeoPoint", :latitude => 25.7823198, :longitude => -80.2660226 }, diff --git a/test/lib/parse/query/group_by_aggregation_test.rb b/test/lib/parse/query/group_by_aggregation_test.rb index 4319ad8..d07c669 100644 --- a/test/lib/parse/query/group_by_aggregation_test.rb +++ b/test/lib/parse/query/group_by_aggregation_test.rb @@ -31,11 +31,6 @@ def test_group_by_count_builds_correct_pipeline def test_group_by_sum_builds_correct_pipeline group_by = Parse::GroupBy.new(@query, :project) - expected_pipeline = [ - { "$group" => { "_id" => "$project", "count" => { "$sum" => "$fileSize" } } }, - { "$project" => { "_id" => 0, "objectId" => "$_id", "count" => 1 } }, - ] - mock_response = Minitest::Mock.new mock_response.expect :success?, true mock_response.expect :result, [] diff --git a/test/lib/parse/query/where_not_between_test.rb b/test/lib/parse/query/where_not_between_test.rb new file mode 100644 index 0000000..f24a22d --- /dev/null +++ b/test/lib/parse/query/where_not_between_test.rb @@ -0,0 +1,93 @@ +require_relative "../../../test_helper" + +class TestWhereNotBetween < Minitest::Test + class NotBetweenPerson < Parse::Object + parse_class "NotBetweenPerson" + property :age, :integer + property :name, :string + end + + def compiled_where(query) + query.compile(encode: false).as_json["where"] + end + + def test_inclusive_range + where = compiled_where(NotBetweenPerson.query.where_not_between(:age, 5..25)) + assert_equal({ "$or" => [{ "age" => { "$lt" => 5 } }, { "age" => { "$gt" => 25 } }] }, where) + end + + def test_exclusive_range_flips_upper_bound_to_gte + where = compiled_where(NotBetweenPerson.query.where_not_between(:age, 5...25)) + assert_equal({ "$or" => [{ "age" => { "$lt" => 5 } }, { "age" => { "$gte" => 25 } }] }, where) + end + + def test_array_form_is_always_inclusive + where = compiled_where(NotBetweenPerson.query.where_not_between(:age, [5, 25])) + assert_equal({ "$or" => [{ "age" => { "$lt" => 5 } }, { "age" => { "$gt" => 25 } }] }, where) + end + + def test_beginless_range_has_no_or_wrapper + where = compiled_where(NotBetweenPerson.query.where_not_between(:age, ..25)) + assert_equal({ "age" => { "$gt" => 25 } }, where) + end + + def test_endless_range_has_no_or_wrapper + where = compiled_where(NotBetweenPerson.query.where_not_between(:age, 5..)) + assert_equal({ "age" => { "$lt" => 5 } }, where) + end + + def test_nests_correctly_alongside_an_unrelated_and_condition + where = compiled_where(NotBetweenPerson.where(:name => "Bob").where_not_between(:age, 5..25)) + assert_equal "Bob", where["name"] + assert_equal [{ "age" => { "$lt" => 5 } }, { "age" => { "$gt" => 25 } }], where["$or"] + end + + def test_raises_on_fully_open_range + assert_raises(ArgumentError) do + NotBetweenPerson.query.where_not_between(:age, nil..nil) + end + end + + def test_raises_on_invalid_value_type + assert_raises(ArgumentError) do + NotBetweenPerson.query.where_not_between(:age, 25) + end + end + + def test_raises_on_array_with_wrong_length + assert_raises(ArgumentError) do + NotBetweenPerson.query.where_not_between(:age, [5]) + end + end + + def test_raises_when_query_already_has_an_or_group_from_or_where + query = NotBetweenPerson.where(:name.eq => "a").or_where(:name.eq => "b") + assert_raises(ArgumentError) do + query.where_not_between(:age, 5..25) + end + end + + def test_raises_when_query_already_has_an_or_group_from_pipe_operator + query = NotBetweenPerson.where(:name.eq => "a") | NotBetweenPerson.where(:name.eq => "b") + assert_raises(ArgumentError) do + query.where_not_between(:age, 5..25) + end + end + + def test_raises_on_second_where_not_between_call + query = NotBetweenPerson.query.where_not_between(:age, 5..25) + assert_raises(ArgumentError) do + query.where_not_between(:name, "a".."m") + end + end + + def test_one_sided_form_does_not_trip_the_existing_or_group_guard + # Beginless/endless negation is a single condition, no new $or is + # introduced, so it's safe even when the query already has one. + query = NotBetweenPerson.where(:name.eq => "a").or_where(:name.eq => "b") + query.where_not_between(:age, 5..) + where = compiled_where(query) + assert where["$or"].present?, "the pre-existing $or group must survive" + assert_equal({ "$lt" => 5 }, where["age"]) + end +end diff --git a/test/lib/parse/query_aggregate_integration_test.rb b/test/lib/parse/query_aggregate_integration_test.rb index eaa71c3..7b2af8f 100644 --- a/test/lib/parse/query_aggregate_integration_test.rb +++ b/test/lib/parse/query_aggregate_integration_test.rb @@ -1131,6 +1131,16 @@ def test_aggregate_arrays_of_pointers_and_dates assert_equal "Date", oldest["__type"], "Date should have __type: Date" assert oldest.key?("iso"), "Date should have iso field" end + + if newest.is_a?(String) + # Verify ISO date format + assert newest.match?(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/), + "Date should be in ISO format: #{newest}" + elsif newest.is_a?(Hash) && newest.key?("__type") + # Parse Date object format + assert_equal "Date", newest["__type"], "Date should have __type: Date" + assert newest.key?("iso"), "Date should have iso field" + end end end rescue => e diff --git a/test/lib/parse/query_compile_snapshot_test.rb b/test/lib/parse/query_compile_snapshot_test.rb index 448c523..e531b10 100644 --- a/test/lib/parse/query_compile_snapshot_test.rb +++ b/test/lib/parse/query_compile_snapshot_test.rb @@ -149,6 +149,51 @@ def test_exists_constraint assert_snapshot(compile(q), name: "exists_constraint", group: GROUP) end + def test_between_range_constraint + # An inclusive Range (`..`) on `.between` compiles identically to the + # 2-element Array form: $gte for the start, $lte for the end. + q = SnapPost.where(:likes.between => 10..100) + assert_snapshot(compile(q), name: "between_range_inclusive", group: GROUP) + end + + def test_between_exclusive_range_constraint + # An exclusive Range (`...`) maps its end to $lt instead of $lte. + q = SnapPost.where(:likes.between => 10...100) + assert_snapshot(compile(q), name: "between_range_exclusive", group: GROUP) + end + + def test_where_not_between_inclusive_range + # A fully-bounded inclusive Range compiles to $or of the two flipped, + # strict comparisons. + q = SnapPost.query.where_not_between(:likes, 10..100) + assert_snapshot(compile(q), name: "where_not_between_inclusive", group: GROUP) + end + + def test_where_not_between_exclusive_range + # An exclusive Range (`...`) flips its upper side to $gte instead of $gt. + q = SnapPost.query.where_not_between(:likes, 10...100) + assert_snapshot(compile(q), name: "where_not_between_exclusive", group: GROUP) + end + + def test_where_not_between_beginless_range + # Beginless range: `between` would only constrain the upper side, so + # its negation is a single one-sided $gt with no $or wrapper. + q = SnapPost.query.where_not_between(:likes, ..100) + assert_snapshot(compile(q), name: "where_not_between_beginless", group: GROUP) + end + + def test_where_not_between_endless_range + q = SnapPost.query.where_not_between(:likes, 10..) + assert_snapshot(compile(q), name: "where_not_between_endless", group: GROUP) + end + + def test_where_not_between_nests_inside_existing_and_condition + # The $or group must nest alongside an unrelated AND'd condition, not + # sweep it into the OR the way #or_where/`|` would. + q = SnapPost.where(:published => true).where_not_between(:likes, 10..100) + assert_snapshot(compile(q), name: "where_not_between_with_and_condition", group: GROUP) + end + def test_tags_all_array_constraint # `:tags.all => [...]` compiles to the `$all` REST operator (not a # pipeline). Snapshot under normalized array order — $all is set-semantic. diff --git a/test/lib/parse/query_get_test.rb b/test/lib/parse/query_get_test.rb new file mode 100644 index 0000000..e6b4101 --- /dev/null +++ b/test/lib/parse/query_get_test.rb @@ -0,0 +1,49 @@ +# encoding: UTF-8 +# frozen_string_literal: true + +require_relative "../../test_helper" + +# Unit tests for Parse::Query#get. Verifies the table name is handed to +# Parse::Object.build as a String so its own Parse::Model.find_class lookup +# (which understands `parse_class` aliasing) resolves the record's class +# correctly, instead of pre-resolving to a Class via Object.const_get. +class QueryGetTest < Minitest::Test + class QueryGetTestMusician < Parse::Object + parse_class "QueryGetTestArtist" + property :name, :string + end + + def mock_client_returning(result) + mock_client = Object.new + mock_client.define_singleton_method(:fetch_object) do |klass, id| + response = Parse::Response.new + response.result = result + response + end + mock_client + end + + def test_get_resolves_aliased_parse_class + query = Parse::Query.new("QueryGetTestArtist") + mock_client = mock_client_returning("objectId" => "abc123", "name" => "Miles") + query.define_singleton_method(:client) { mock_client } + + object = query.get("abc123") + + assert_instance_of QueryGetTestMusician, object + assert_equal "abc123", object.id + assert_equal "Miles", object.name + end + + def test_get_returns_pointer_with_correct_class_name_for_unregistered_table + query = Parse::Query.new("QueryGetTestNoSuchTable") + mock_client = mock_client_returning("objectId" => "xyz789") + query.define_singleton_method(:client) { mock_client } + + object = query.get("xyz789") + + assert_instance_of Parse::Pointer, object + assert_equal "xyz789", object.id + assert_equal "QueryGetTestNoSuchTable", object.parse_class + end +end diff --git a/test/lib/parse/query_integration_fast_integration_test.rb b/test/lib/parse/query_integration_fast_integration_test.rb index f231fe6..446b3c9 100644 --- a/test/lib/parse/query_integration_fast_integration_test.rb +++ b/test/lib/parse/query_integration_fast_integration_test.rb @@ -52,7 +52,7 @@ def test_simple_query_operations # Test first with_timeout(2, "first query") do - first_result = GameScore.query.first + GameScore.query.first # first might be nil if no data, that's ok assert true, "First query completed" end @@ -89,7 +89,7 @@ def test_class_level_methods # Test class-level first with_timeout(2, "class first") do - first = GameScore.first + GameScore.first # might be nil, that's ok assert true, "Class first completed" end diff --git a/test/lib/parse/query_integration_test.rb b/test/lib/parse/query_integration_test.rb index a7ddc28..8171a41 100644 --- a/test/lib/parse/query_integration_test.rb +++ b/test/lib/parse/query_integration_test.rb @@ -127,7 +127,7 @@ def test_simple_query_without_setup with_timeout(2, "simple query") do # Just try to query existing data without creating new data query = GameScore.query.limit(1) - results = query.results + query.results # Don't assert anything about results - just verify query doesn't hang assert true, "Query completed without timeout" end @@ -1546,8 +1546,6 @@ def test_date_and_time_queries # Create posts with different timestamps now = Time.now - yesterday = now - 24 * 60 * 60 - last_week = now - 7 * 24 * 60 * 60 # Note: Parse Server automatically manages createdAt/updatedAt post1 = Post.new(title: "Recent Post", content: "New content") @@ -1583,9 +1581,6 @@ def test_mixed_where_conditions_with_dates # Create test data with various attributes now = Time.now - hour_ago = now - 3600 - day_ago = now - 86400 - week_ago = now - 604800 # Create players with different attributes and join dates players = [] diff --git a/test/lib/parse/query_or_and_test.rb b/test/lib/parse/query_or_and_test.rb index 9083871..6377a9b 100644 --- a/test/lib/parse/query_or_and_test.rb +++ b/test/lib/parse/query_or_and_test.rb @@ -236,7 +236,7 @@ def test_table_validation # This should raise an error if we had another model begin - or_query = Parse::Query.or(product_query) + Parse::Query.or(product_query) puts "Single table OR succeeded" rescue ArgumentError => e puts "Single table OR failed: #{e.message}" diff --git a/test/lib/parse/query_pointers_contains_integration_test.rb b/test/lib/parse/query_pointers_contains_integration_test.rb index af70c06..13014cb 100644 --- a/test/lib/parse/query_pointers_contains_integration_test.rb +++ b/test/lib/parse/query_pointers_contains_integration_test.rb @@ -537,7 +537,6 @@ def test_edge_cases_and_error_handling # Test 6: Contains with non-existent pointer puts "--- Test 6: Contains with non-existent pointer ---" fake_author_pointer = Parse::Pointer.new("QueryTestAuthor", "fakeid456") - fake_book_pointer = Parse::Pointer.new("QueryTestBook", "fakebookid789") library = QueryTestLibrary.new( name: "Edge Case Library", diff --git a/test/lib/parse/security_hardening_test.rb b/test/lib/parse/security_hardening_test.rb index 6d69a7d..0174758 100644 --- a/test/lib/parse/security_hardening_test.rb +++ b/test/lib/parse/security_hardening_test.rb @@ -508,7 +508,7 @@ class SHAliasOwner < Parse::Object def test_array_parse_objects_ignores_hash_className_when_caller_specifies arr = [{ "__type" => "Pointer", "className" => "_Session", "objectId" => "evil" }] - out, _err = capture_io { arr.parse_objects("Author") } + _, _err = capture_io { arr.parse_objects("Author") } objs = arr.parse_objects("Author") assert_equal 1, objs.length assert_equal "Author", objs.first.parse_class diff --git a/test/lib/parse/time_query_integration_test.rb b/test/lib/parse/time_query_integration_test.rb index f1c3b95..28a2774 100644 --- a/test/lib/parse/time_query_integration_test.rb +++ b/test/lib/parse/time_query_integration_test.rb @@ -319,8 +319,6 @@ def test_utc_timezone_handling # Create times in different timezone formats utc_time = Time.now.utc local_time = Time.now - datetime_utc = DateTime.now.utc - datetime_local = DateTime.now # Create event with UTC time utc_event = Event.new({ diff --git a/test/lib/parse/upsert_methods_integration_test.rb b/test/lib/parse/upsert_methods_integration_test.rb index 7e29e07..50e39d2 100644 --- a/test/lib/parse/upsert_methods_integration_test.rb +++ b/test/lib/parse/upsert_methods_integration_test.rb @@ -323,7 +323,7 @@ def test_performance_comparison_across_methods # Test create_or_update! performance (with changes) start_time = Time.now 5.times do |i| - result = UpsertTestUser.create_or_update!({ email: "perf@example.com" }, { age: 35 + i }) + UpsertTestUser.create_or_update!({ email: "perf@example.com" }, { age: 35 + i }) end create_or_update_with_change_time = Time.now - start_time diff --git a/test/lib/parse/webhook_triggers_test.rb b/test/lib/parse/webhook_triggers_test.rb index 1d2d9ef..a0679c2 100644 --- a/test/lib/parse/webhook_triggers_test.rb +++ b/test/lib/parse/webhook_triggers_test.rb @@ -78,7 +78,7 @@ def test_before_save_trigger } client_payload = Parse::Webhooks::Payload.new(client_payload_data) - result = Parse::Webhooks.call_route(:before_save, "TestObject", client_payload) + Parse::Webhooks.call_route(:before_save, "TestObject", client_payload) assert hook_called, "before_save hook should be called for client" assert hook_payload.before_save?, "Payload should identify as before_save" @@ -153,7 +153,7 @@ def test_after_save_trigger # Add after_create callback for new objects test_object.define_singleton_method(:run_after_create_callbacks) { callback_executed = true } - result = Parse::Webhooks.call_route(:after_save, "TestObject", client_payload) + Parse::Webhooks.call_route(:after_save, "TestObject", client_payload) Parse::Webhooks.run_after_save_chain(client_payload) assert hook_called, "after_save hook should be called for client" @@ -222,7 +222,7 @@ def test_before_delete_trigger client_payload = Parse::Webhooks::Payload.new(client_payload_data) client_payload.define_singleton_method(:parse_object) { test_object } - result = Parse::Webhooks.call_route(:before_delete, "TestObject", client_payload) + Parse::Webhooks.call_route(:before_delete, "TestObject", client_payload) assert hook_called, "before_delete hook should be called for client" assert hook_payload.before_delete?, "Payload should identify as before_delete" @@ -282,7 +282,7 @@ def test_after_delete_trigger } client_payload = Parse::Webhooks::Payload.new(client_payload_data) - result = Parse::Webhooks.call_route(:after_delete, "TestObject", client_payload) + Parse::Webhooks.call_route(:after_delete, "TestObject", client_payload) assert hook_called, "after_delete hook should be called for client" assert hook_payload.after_delete?, "Payload should identify as after_delete" @@ -347,7 +347,7 @@ def test_before_find_trigger client_payload = Parse::Webhooks::Payload.new(client_payload_data) client_payload.instance_variable_set(:@webhook_class, "TestObject") - result = Parse::Webhooks.call_route(:before_find, "TestObject", client_payload) + Parse::Webhooks.call_route(:before_find, "TestObject", client_payload) assert hook_called, "before_find hook should be called for client" assert hook_payload.before_find?, "Payload should identify as before_find" @@ -421,7 +421,7 @@ def test_after_find_trigger client_payload = Parse::Webhooks::Payload.new(client_payload_data) client_payload.instance_variable_set(:@webhook_class, "TestObject") - result = Parse::Webhooks.call_route(:after_find, "TestObject", client_payload) + Parse::Webhooks.call_route(:after_find, "TestObject", client_payload) assert hook_called, "after_find hook should be called for client" assert hook_payload.after_find?, "Payload should identify as after_find" @@ -534,7 +534,7 @@ def test_multiple_trigger_hooks } before_payload = Parse::Webhooks::Payload.new(before_payload_data) - result = Parse::Webhooks.call_route(:before_save, "TestObject", before_payload) + Parse::Webhooks.call_route(:before_save, "TestObject", before_payload) assert_equal ["before2"], execution_order, "Only the last before_save hook should execute" puts "✅ Single before_save hook behavior works correctly" @@ -580,7 +580,7 @@ def test_trigger_error_handling # Direct call_route won't raise the error, but the error! method would be called # This tests that the conditional logic works correctly begin - result = Parse::Webhooks.call_route(:before_save, "TestObject", client_payload) + Parse::Webhooks.call_route(:before_save, "TestObject", client_payload) flunk "Should have raised ResponseError for client request" rescue Parse::Webhooks::ResponseError => e assert_equal "Client validation failed", e.message, "Should have correct error message" @@ -616,7 +616,7 @@ def test_wildcard_trigger_routing payload = Parse::Webhooks::Payload.new(payload_data) payload.define_singleton_method(:parse_object) { nil } - result = Parse::Webhooks.call_route(:after_save, "TestObject", payload) + Parse::Webhooks.call_route(:after_save, "TestObject", payload) assert specific_called, "Specific hook should be called" refute wildcard_called, "Wildcard hook should not be called when specific exists" @@ -639,7 +639,7 @@ def test_wildcard_trigger_routing assert_nil result, "No specific route should exist" # Then try wildcard route - result = Parse::Webhooks.call_route(:after_save, "*", unknown_payload) + Parse::Webhooks.call_route(:after_save, "*", unknown_payload) refute specific_called, "Specific hook should not be called" assert wildcard_called, "Wildcard hook should be called for unknown class" diff --git a/test/snapshots/query_compile/between_range_exclusive.json b/test/snapshots/query_compile/between_range_exclusive.json new file mode 100644 index 0000000..581da8c --- /dev/null +++ b/test/snapshots/query_compile/between_range_exclusive.json @@ -0,0 +1,8 @@ +{ + "where": { + "likes": { + "$gte": 10, + "$lt": 100 + } + } +} diff --git a/test/snapshots/query_compile/between_range_inclusive.json b/test/snapshots/query_compile/between_range_inclusive.json new file mode 100644 index 0000000..f18f16f --- /dev/null +++ b/test/snapshots/query_compile/between_range_inclusive.json @@ -0,0 +1,8 @@ +{ + "where": { + "likes": { + "$gte": 10, + "$lte": 100 + } + } +} diff --git a/test/snapshots/query_compile/where_not_between_beginless.json b/test/snapshots/query_compile/where_not_between_beginless.json new file mode 100644 index 0000000..b4862ae --- /dev/null +++ b/test/snapshots/query_compile/where_not_between_beginless.json @@ -0,0 +1,7 @@ +{ + "where": { + "likes": { + "$gt": 100 + } + } +} diff --git a/test/snapshots/query_compile/where_not_between_endless.json b/test/snapshots/query_compile/where_not_between_endless.json new file mode 100644 index 0000000..76b5655 --- /dev/null +++ b/test/snapshots/query_compile/where_not_between_endless.json @@ -0,0 +1,7 @@ +{ + "where": { + "likes": { + "$lt": 10 + } + } +} diff --git a/test/snapshots/query_compile/where_not_between_exclusive.json b/test/snapshots/query_compile/where_not_between_exclusive.json new file mode 100644 index 0000000..031ebcc --- /dev/null +++ b/test/snapshots/query_compile/where_not_between_exclusive.json @@ -0,0 +1,16 @@ +{ + "where": { + "$or": [ + { + "likes": { + "$lt": 10 + } + }, + { + "likes": { + "$gte": 100 + } + } + ] + } +} diff --git a/test/snapshots/query_compile/where_not_between_inclusive.json b/test/snapshots/query_compile/where_not_between_inclusive.json new file mode 100644 index 0000000..5a5a05c --- /dev/null +++ b/test/snapshots/query_compile/where_not_between_inclusive.json @@ -0,0 +1,16 @@ +{ + "where": { + "$or": [ + { + "likes": { + "$lt": 10 + } + }, + { + "likes": { + "$gt": 100 + } + } + ] + } +} diff --git a/test/snapshots/query_compile/where_not_between_with_and_condition.json b/test/snapshots/query_compile/where_not_between_with_and_condition.json new file mode 100644 index 0000000..665176a --- /dev/null +++ b/test/snapshots/query_compile/where_not_between_with_and_condition.json @@ -0,0 +1,17 @@ +{ + "where": { + "$or": [ + { + "likes": { + "$lt": 10 + } + }, + { + "likes": { + "$gt": 100 + } + } + ], + "published": true + } +} diff --git a/test/support/docker_helper.rb b/test/support/docker_helper.rb index 63e4959..74c0a39 100644 --- a/test/support/docker_helper.rb +++ b/test/support/docker_helper.rb @@ -16,7 +16,7 @@ def start! puts "Starting Parse Server test container..." - stdout, stderr, status = Open3.capture3("docker-compose -f #{COMPOSE_FILE} up -d") + _stdout, stderr, status = Open3.capture3("docker-compose -f #{COMPOSE_FILE} up -d") if status.success? wait_for_server diff --git a/test/support/test_server.rb b/test/support/test_server.rb index 10b58c8..5faa993 100644 --- a/test/support/test_server.rb +++ b/test/support/test_server.rb @@ -37,14 +37,14 @@ def server_available? uri = URI(Parse::Client.client.server_url + "/health") response = Net::HTTP.get_response(uri) response.code == "200" - rescue StandardError => e + rescue StandardError # Fallback: Try to check if Parse is responding at all begin uri = URI(Parse::Client.client.server_url) response = Net::HTTP.get_response(uri) # Parse Server typically returns 404 or 401 for root path but it means server is up ["200", "404", "401", "403"].include?(response.code) - rescue StandardError => e2 + rescue StandardError false end end @@ -82,12 +82,12 @@ def reset_database! begin obj.destroy total_deleted += 1 - rescue => e + rescue # Silent failure - continue with other objects end end end - rescue StandardError => e + rescue StandardError # Silent failure - continue with other classes end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 88ce54d..9a608b1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -57,7 +57,7 @@ def test_scalar_values module Minitest module Assertions def refute_raises(*exp) - msg = "#{exp.pop}.\n" if String === exp.last + "#{exp.pop}.\n" if String === exp.last begin yield @@ -65,7 +65,6 @@ def refute_raises(*exp) return e if exp.include? Minitest::Skip raise e rescue Exception => e - exp = exp.first if exp.size == 1 flunk "unexpected exception raised: #{e}" end end