From bd789b8586b71c95d7b85483bb4b6838bba54a62 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:50:16 +0400 Subject: [PATCH] Fix S3/SFTP job contracts, the JSON content type check and dead bulk_root_url An audit against the 74 documented operations found several places where the SDK sends something the live API rejects, or reads a failure as a success. listS3files was going through bulk_get, which puts it under /v2. Every other S3 and SFTP/FTP call lives on the bulkapi root, so the request 404ed. It now uses bulk_root_get, and the spec stub that asserted the wrong URL is fixed with it. validate_s3_file and score_s3_file took return_url through **options and never required it, so the live API rejected the job with "This field is required". They now go through bulk_root_file_job, the helper the other six jobs already share, rather than rebuilding the parameter hash twice. export_s3_file demanded export_bucket, but it is optional: omitted, the export goes to the bucket in the account's settings, the way export_sftp_file's export_path already did. list_inbox_placement_tests sent pageSize. The API accepts it and ignores it: page_size=1 paginates, pageSize=1 returns the whole list. It now sends page_size, bounds it to 1-100, and accepts createdAfter/createdBefore, which were missing entirely. The remaining argument checks catch what the API rejects anyway, before the request: a bulk email finder file needs first_name_column or full_name_column to have a name to work from, and a new inbox placement test needs a name of at most 64 characters, a testType of 1, 2 or 3, and a senderEmail when the type is 1 - that one would otherwise spend inbox-test credits to be told so. find_email's first_name goes the other way: /guessformat only requires a domain or a company name, so it is now optional. It stays the first positional argument, and find_domain and the deprecated guessformat are untouched. Two things underneath the endpoints: The content type was compared with == 'application/json', so a response typed 'application/json; charset=utf-8' skipped JSON parsing and came back as a raw String. An {"error": ...} body then read as a success instead of raising. __json_content_type__ matches the media type and ignores the parameters. bulk_root_url / ZEROBOUNCE_BULK_ROOT_URL was written in Configuration and never read, so a documented-looking environment variable silently did nothing. It is now the root the S3/SFTP jobs use when it is set. It defaults to nil instead of the live constant, so leaving it alone still derives the root from bulk_api_root_url with the /v2 stripped and nobody pointing that at a mock server loses the derivation. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 9 +- CHANGELOG.md | 19 +- README.md | 18 +- lib/zerobounce.rb | 11 +- lib/zerobounce/base_request.rb | 27 ++- lib/zerobounce/configuration.rb | 12 +- lib/zerobounce/mock_request.rb | 12 +- lib/zerobounce/priority3.rb | 109 ++++++--- lib/zerobounce/request.rb | 8 +- live/live_api.rb | 23 +- spec/base_request_spec.rb | 22 ++ spec/bulk_jobs_spec.rb | 45 +++- spec/fixtures/priority3_finder.csv | 4 +- spec/priority3_spec.rb | 373 ++++++++++++++++++++++++++--- 14 files changed, 598 insertions(+), 94 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e67424a..6c90b94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,9 +43,12 @@ CI runs `bundle exec rspec` only. `rake default` also runs RuboCop, which report ## Rules - **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 and SFTP bulk endpoints live under https://www.zerobounce.net/docs/api-integrations, not the main API reference. -- **Four base URLs.** `api_root_url`, `bulk_api_root_url` (ends in `/v2/`), `bulk_root_url` (the S3/SFTP jobs, **no** `/v2`), and `list_evaluator_root_url` / `inbox_placement_root_url`. The last three are separate values rather than paths built from `bulk_api_root_url`, so overriding that alone leaves a test pointing at the live API. -- **Where the live API and the docs disagree, follow the API and say so in a comment at the call site.** The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer`, not `api_key`; its listing is `GET /tests`, not `/test`; `send-evaluated-file` takes `file_id` as a form field, not a file upload; the S3 and SFTP jobs require `return_url` and `file_name`, `validate_sftp_file` included; `/filters/*` answers a *success* with a bare `{"Message": ...}` body; the Warmup `check-domain-dmarc` takes an email address, not a domain; and a failed SMTP login in `test-smtp-configuration` comes back as an HTTP 500. -- **`export_sftp_file` sends `export_path`**, not the `export_bucket` that `export_s3_file` sends. +- **Four base URLs.** `api_root_url`, `bulk_api_root_url` (ends in `/v2/`), `bulk_root_url` (the S3/SFTP jobs, **no** `/v2`), and `list_evaluator_root_url` / `inbox_placement_root_url`. `list_evaluator_root_url` and `inbox_placement_root_url` are separate values rather than paths built from `bulk_api_root_url`, so overriding that alone leaves a test pointing at the live API. `bulk_root_url` is the exception: it defaults to `nil` and `BaseRequest.bulk_api_root_without_v2` then derives it from `bulk_api_root_url` with the `/v2` stripped. Set it (or `ZEROBOUNCE_BULK_ROOT_URL`) to move only the S3/SFTP jobs. +- **Where the live API and the docs disagree, follow the API and say so in a comment at the call site.** The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer`, not `api_key`; its listing is `GET /tests`, not `/test`, and that listing's page size parameter is `page_size`, not the camelCase `pageSize` the rest of that API uses (`pageSize` is accepted and ignored); `send-evaluated-file` takes `file_id` as a form field, not a file upload; the S3 and SFTP jobs require `return_url` and `file_name`, `validate_sftp_file` included; `/filters/*` answers a *success* with a bare `{"Message": ...}` body; the Warmup `check-domain-dmarc` takes an email address, not a domain; and a failed SMTP login in `test-smtp-configuration` comes back as an HTTP 500. +- **`export_sftp_file` sends `export_path`**, not the `export_bucket` that `export_s3_file` sends. Both are optional; omitted, the export goes to the account's configured destination. +- **The eight S3/SFTP file jobs go through `Priority3#bulk_root_file_job`.** It is the one place that enforces `file_name` and `return_url` and turns booleans into the strings `true`/`false`. `validate_s3_file` and `score_s3_file` use it too, so do not rebuild the parameter hash per endpoint. +- **`listS3files` is on the bulkapi root, not `/v2`.** It is the only S3 call that is a GET, which is how it ended up on `bulk_get` (and a 404) once before. +- **A JSON content type is matched by media type, not `==`.** The API types some responses `application/json; charset=utf-8`; `BaseRequest.__json_content_type__` is the only thing that should decide whether a body is parsed. - **`{"success": false}` on an HTTP 200 is a failure.** The API answers an unknown bulk file id that way; both `bulk_get` and `__parse_json_response__` raise on it. - **A body that is nothing but `Message` means different things per endpoint.** On the validate endpoints it is a rejection; on `/filters/add`, `/filters/delete` and `/filters/list` it is the success. `Request::FILTER_PATHS` is the exception list. - **`Gemfile.lock` pins the gem's own version.** A version bump must update both `lib/zerobounce/version.rb` and `Gemfile.lock`, or `bundle install --deployment` in CI fails. diff --git a/CHANGELOG.md b/CHANGELOG.md index 85fb091..3245c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ * `validate` accepts `domain_info`, `greylist_processing` and `callback_url`. An option left out is not sent; booleans go on the wire as the strings `true`/`false`. `callback_url` is where the greylist retry posts its result, so it is rejected without `greylist_processing`, and when blank, before any request is sent. * Add the rest of the documented bulk surface: `email_finder_s3_file`, `domain_search_s3_file`, and the SFTP/FTP jobs `validate_sftp_file`, `score_sftp_file`, `email_finder_sftp_file`, `domain_search_sftp_file` and `export_sftp_file`. These are on the bulkapi host **without** `/v2`. `validate_sftp_file`'s file name is optional - passing `nil` uses the input bucket from your ZeroBounce settings - and `export_sftp_file` takes `export_path`, not the `export_bucket` `export_s3_file` sends. * Add the Inbox Placement Tester: the API key goes on the query string, bodies are camelCase JSON, and unset fields are left out. -* `Configuration` gains `bulk_root_url`, `list_evaluator_root_url` and `inbox_placement_root_url`. These are separate base URLs rather than paths built from `bulk_api_root_url`, so overriding that alone leaves them pointing at the live API. +* `Configuration` gains `bulk_root_url`, `list_evaluator_root_url` and `inbox_placement_root_url`. `list_evaluator_root_url` and `inbox_placement_root_url` are separate base URLs rather than paths built from `bulk_api_root_url`, so overriding that alone leaves them pointing at the live API. `bulk_root_url` (`ZEROBOUNCE_BULK_ROOT_URL`) now actually reaches the request layer: set, it is the root for the S3 and SFTP/FTP jobs; unset, that root is still derived from `bulk_api_root_url` with the `/v2` stripped. It was previously written and never read, so the environment variable looked supported and did nothing. * `get_list_evaluator_result` uses the list evaluator's own base URL, encodes the file id as a single path segment, and rejects a blank id before a request is sent. * An error body carrying a `detail` key (the Inbox Placement Tester is a Django REST framework app) is now raised with that text. * Add `Zerobounce::Warmup`, a client for the Warmup API. It is a separate product with its own host (`https://warmup-api.zerobounce.net`), its own team-scoped key sent in an `API-Key` header, and all 26 documented endpoints, including the seed-list and statistics CSV downloads. @@ -14,6 +14,14 @@ * The Inbox Placement Tester authenticates with an `Authorization: Bearer` header, not an `api_key` parameter, so the key no longer reaches a query string that gets logged. Its listing is `GET /tests`; `/test` answers `Method "GET" not allowed`. * `send_evaluated_file` takes the list evaluator's `file_id` as a form field, not a file upload, so its argument is now a file id. * The S3 and SFTP jobs require `file_name` and `return_url`, `validate_sftp_file` included, although the docs list `file_name` as optional for that one. +* Fixed: `list_s3_files` asked for `bulkapi/v2/listS3files`. Like every other S3 and SFTP/FTP call it lives on the bulkapi **root**, so the request 404ed. +* Fixed: a response typed `application/json; charset=utf-8` skipped JSON parsing, because the content type was compared with `==` against the bare media type. The raw body came back as a `String`, so an `{"error": ...}` payload read as a success instead of raising. The media type is now matched on its own, ignoring the parameters after the semicolon. Affects the bulk file status, get and delete calls, the AI scoring, email finder and domain search bulk jobs, and `list_s3_files`. +* Fixed: `validate_s3_file` and `score_s3_file` took `return_url` through `**options` and never required it, so the live API rejected the job. They now go through the same helper as the other six S3/SFTP jobs, which requires `file_name` and `return_url` and sends `has_header_row` and `remove_duplicates` in the documented shape. `has_header_row` is now a named keyword and defaults to `false`. +* Fixed: `export_s3_file` demanded `export_bucket`. It is optional - omitted, the export goes to the bucket in your ZeroBounce settings - the way `export_sftp_file`'s `export_path` already was. +* Fixed: `list_inbox_placement_tests` sent `pageSize`, which the API accepts and then ignores; the parameter it honours is `page_size`. It also accepts `created_after` / `created_before` (sent as `createdAfter` / `createdBefore`), and rejects a page size outside 1-100 before a request is sent. +* Fixed: `send_email_finder_file` now requires `first_name_column` or `full_name_column`. With neither, the finder has no name to build a local part from. +* Fixed: `create_inbox_placement_test` checks the documented bounds before spending inbox-test credits: `name` at most 64 characters, `test_type` one of 1, 2 or 3, and `sender_email` required when `test_type` is 1. +* Fixed: `find_email`'s `first_name` is optional. `/guessformat` only requires a domain or a company name; without a name it answers with the domain's format. It is still the first positional argument, so existing calls are unchanged, and `find_domain` and the deprecated `guessformat` keep their own signatures. * Fixed: `add_filter`, `delete_filter` and `list_filters` raised on success. They answer with a bare `{"Message": "..."}` body, which everywhere else means the request was rejected. * Fixed: `{"success": false}` on an HTTP 200 raises instead of being returned as a result. The API answers an unknown bulk file id that way. * Security: a Warmup API key with surrounding whitespace or control characters is rejected, because the transport would otherwise quote it unredacted in its own error; a Warmup base URL must be `https://`; and the Warmup SMTP test masks the username and password out of every error it raises. @@ -34,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `score_email` for `GET` and `POST /scoring`. - Bulk email finder, bulk domain search, list evaluator, filters, and S3 (`validate_s3_file`, `score_s3_file`, `export_s3_file`, `list_s3_files`). - `Zerobounce::Client` so each API key stays on its own thread. Module methods still share `Zerobounce.config`. +- `Configuration#bulk_root_url` / `ZEROBOUNCE_BULK_ROOT_URL` is read by the request layer and overrides the root used by the S3 and SFTP/FTP jobs. ### Changed @@ -49,6 +58,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Empty and non-JSON bodies are a short error. A repeated `Error:` / `[Error]:` prefix is removed. - `validate_batch` raises for an account-level failure (HTTP 200, error for address `all`, such as an invalid API key or no credits). - Bulk email finder and domain search send `has_header_row` when it is false. A column index of `0` is sent when the caller passes it. +- `list_s3_files` is sent to the bulkapi root instead of `/v2`, where it 404ed. +- A `Content-Type` of `application/json; charset=utf-8` is parsed as JSON, so an error payload raises instead of being returned as a raw `String`. +- `validate_s3_file` and `score_s3_file` require `return_url`, which the live API rejects the job without. +- `export_s3_file`'s `export_bucket` is optional and falls back to the account's settings. +- `list_inbox_placement_tests` sends `page_size` (the API ignores `pageSize`), accepts `created_after` / `created_before`, and bounds the page size to 1-100. +- `send_email_finder_file` requires `first_name_column` or `full_name_column`. +- `create_inbox_placement_test` enforces the documented `name`, `test_type` and `sender_email` rules. +- `find_email`'s `first_name` is optional. - An empty API key is rejected before a request is sent. - `validate` timeout must be 3–60 seconds. `validate_batch` timeout must be 10–120 seconds. diff --git a/README.md b/README.md index 4dd168d..d3066d3 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ Zerobounce.configure do |config| # Optional: Set custom API URLs config.api_root_url = 'https://api.zerobounce.net/v2' config.bulk_api_root_url = 'https://bulkapi.zerobounce.net/v2' + # Optional: the root the S3 and SFTP/FTP jobs use (the bulk host without + # /v2). Left unset it is derived from bulk_api_root_url. + config.bulk_root_url = 'https://bulkapi.zerobounce.net' end ``` @@ -82,6 +85,8 @@ Create a `.env` file in your project root: ZEROBOUNCE_API_KEY=your_api_key_here ZEROBOUNCE_API_URL=https://api.zerobounce.net/v2 ZEROBOUNCE_BULK_API_URL=https://bulkapi.zerobounce.net/v2 +# Optional. Only needed to point the S3 and SFTP/FTP jobs somewhere of their own. +ZEROBOUNCE_BULK_ROOT_URL=https://bulkapi.zerobounce.net ``` The gem will automatically load these environment variables when initialized. No additional configuration needed in your code. @@ -92,6 +97,8 @@ Set environment variables in your system: export ZEROBOUNCE_API_KEY=your_api_key_here export ZEROBOUNCE_API_URL=https://api.zerobounce.net/v2 export ZEROBOUNCE_BULK_API_URL=https://bulkapi.zerobounce.net/v2 +# Optional. Only needed to point the S3 and SFTP/FTP jobs somewhere of their own. +export ZEROBOUNCE_BULK_ROOT_URL=https://bulkapi.zerobounce.net ``` @@ -101,6 +108,9 @@ export ZEROBOUNCE_BULK_API_URL=https://bulkapi.zerobounce.net/v2 If you don't specify `api_root_url` or `bulk_api_root_url`, the gem will use the following defaults: - **Main API**: `https://api.zerobounce.net/v2` - **Bulk API**: `https://bulkapi.zerobounce.net/v2` +- **S3 and SFTP/FTP jobs**: `https://bulkapi.zerobounce.net` — the bulk host *without* `/v2` + +`bulk_root_url` (`ZEROBOUNCE_BULK_ROOT_URL`) is the one base URL that is derived rather than defaulted: left unset, it is `bulk_api_root_url` with the trailing `/v2` stripped, so pointing `bulk_api_root_url` at a mock server moves the S3 and SFTP/FTP endpoints with it. Set it to send those jobs somewhere on their own. These defaults are defined in the `Zerobounce::ApiUrls` class constants: - `Zerobounce::ApiUrls::DEFAULT_URL` - Main API URL (Global) @@ -149,8 +159,8 @@ end #### Environment Variable Priority The configuration follows this priority order (highest to lowest): 1. Explicitly set in code (`config.api_root_url = '...'`) -2. Environment variables (`ZEROBOUNCE_API_URL`, `ZEROBOUNCE_BULK_API_URL`) -3. Default constants (`ApiUrls::DEFAULT_URL`, `ApiUrls::BULK_DEFAULT_URL`) +2. Environment variables (`ZEROBOUNCE_API_URL`, `ZEROBOUNCE_BULK_API_URL`, `ZEROBOUNCE_BULK_ROOT_URL`) +3. Default constants (`ApiUrls::DEFAULT_URL`, `ApiUrls::BULK_DEFAULT_URL`); `bulk_root_url` falls back to `bulk_api_root_url` without `/v2` Credits ```ruby @@ -645,6 +655,10 @@ Zerobounce.find_email("John", company_name: "Zero Bounce") # With additional name information for better accuracy Zerobounce.find_email("John", domain: "zerobounce.net", middle_name: "Deere", last_name: "Doe") + +# The first name is optional. Without one the call answers with the domain's +# address format rather than one person's address. +Zerobounce.find_email(domain: "zerobounce.net") ``` Find Domain Information diff --git a/lib/zerobounce.rb b/lib/zerobounce.rb index 22dde6e..5d5d9ef 100644 --- a/lib/zerobounce.rb +++ b/lib/zerobounce.rb @@ -588,7 +588,8 @@ def guessformat(domain, first_name: '', middle_name: '', last_name: '') # # @option [String] domain Domain to search within (e.g. example.com). Required if company_name is not provided. # @option [String] company_name Company name to search within (e.g. Example). Required if domain is not provided. - # @option [String] first_name First name of target. + # @param [String] first_name First name of target. Optional: left out, the + # call answers with the domain's format rather than one person's address. # @option [String] middle_name Middle name of target. # @option [String] last_name Last name of target. # @@ -601,7 +602,10 @@ def guessformat(domain, first_name: '', middle_name: '', last_name: '') # "did_you_mean": "", # "failure_reason": "" # } - def find_email(first_name, domain: '', company_name: '', middle_name: '', last_name: '') + # first_name is optional: /guessformat only requires a domain or a company + # name, and answers the domain's format when no name is given. It stays the + # first positional argument, so every existing call keeps working. + def find_email(first_name = nil, domain: '', company_name: '', middle_name: '', last_name: '') # Validate that exactly one of domain or company_name is provided if (domain.nil? || domain.empty?) && (company_name.nil? || company_name.empty?) raise ArgumentError, "Either domain or company_name must be provided" @@ -609,7 +613,8 @@ def find_email(first_name, domain: '', company_name: '', middle_name: '', last_n raise ArgumentError, "Only one of domain or company_name can be provided" end - params = { first_name: first_name } + params = {} + params[:first_name] = first_name unless first_name.nil? || first_name.to_s.empty? params[:domain] = domain unless domain.nil? || domain.empty? params[:company_name] = company_name unless company_name.nil? || company_name.empty? params[:middle_name] = middle_name unless middle_name.nil? || middle_name.empty? diff --git a/lib/zerobounce/base_request.rb b/lib/zerobounce/base_request.rb index df4a1ce..5ae96ba 100644 --- a/lib/zerobounce/base_request.rb +++ b/lib/zerobounce/base_request.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'json' +require 'zerobounce/api_urls' require 'zerobounce/response_error' # https://github.com/rest-client/rest-client @@ -177,9 +178,31 @@ def self.ipt_request(method, path, params = {}, json: nil) __parse_json_response__(response) end + # Whether a Content-Type names a JSON body. + # + # The API types most responses 'application/json' but some + # 'application/json; charset=utf-8'. Compared with ==, the second kind + # skipped JSON parsing entirely, so an {"error": ...} payload came back as + # a raw String and read as a success. Only the media type decides; the + # parameters after the semicolon are ignored. + def self.__json_content_type__(content_type) + media_type = content_type.to_s.split(';', 2).first.to_s.strip.downcase + media_type == 'application/json' || media_type.end_with?('+json') + end + + # The root for the S3 and SFTP/FTP jobs: the bulkapi host WITHOUT /v2. + # + # +bulk_root_url+ (ZEROBOUNCE_BULK_ROOT_URL) wins when it is set, so these + # endpoints can be pointed at a mock server on their own. Left unset it is + # derived from +bulk_api_root_url+ with the /v2 stripped, so overriding + # that one alone still moves these endpoints with it. def self.bulk_api_root_without_v2 - root = Zerobounce.configuration.bulk_api_root_url.to_s.sub(%r{/v2/?$}, '') - __root_without_trailing_slashes__(root) + configured = Zerobounce.configuration.bulk_root_url.to_s.strip + return __root_without_trailing_slashes__(configured) unless configured.empty? + + derived = Zerobounce.configuration.bulk_api_root_url.to_s.sub(%r{/v2/?$}, '') + derived = ApiUrls::BULK_ROOT_URL if derived.strip.empty? + __root_without_trailing_slashes__(derived) end # Whether a "success" field means failure. The API sends it as a JSON diff --git a/lib/zerobounce/configuration.rb b/lib/zerobounce/configuration.rb index c19d1b7..b560a4a 100644 --- a/lib/zerobounce/configuration.rb +++ b/lib/zerobounce/configuration.rb @@ -23,6 +23,10 @@ module Zerobounce # @attr [String] bulk_api_root_url # The Zerobounce bulk API root URL. # + # @attr [String, nil] bulk_root_url + # The root for the S3 and SFTP/FTP bulk jobs: the bulkapi host without the + # /v2 path. Defaults to nil, which derives it from {#bulk_api_root_url}. + # # @attr [Array] valid_statues # The statuses that are considered valid by {Response#valid?}. class Configuration @@ -33,9 +37,13 @@ def initialize(mock = false) self.apikey = ENV['ZEROBOUNCE_API_KEY'] self.api_root_url = ENV['ZEROBOUNCE_API_URL'] || ApiUrls::DEFAULT_URL self.bulk_api_root_url = ENV['ZEROBOUNCE_BULK_API_URL'] || ApiUrls::BULK_DEFAULT_URL - # These three are separate base URLs rather than paths built from + # The S3/SFTP root is the only one that can be derived from another base + # URL, so it is left unset unless it is asked for: BaseRequest then takes + # bulk_api_root_url with the /v2 stripped. Set it - in code or through + # ZEROBOUNCE_BULK_ROOT_URL - to point those jobs somewhere on their own. + self.bulk_root_url = ENV['ZEROBOUNCE_BULK_ROOT_URL'] + # These two are separate base URLs rather than paths built from # bulk_api_root_url, so overriding that alone leaves them on the live API. - self.bulk_root_url = ENV['ZEROBOUNCE_BULK_ROOT_URL'] || ApiUrls::BULK_ROOT_URL self.list_evaluator_root_url = ENV['ZEROBOUNCE_LIST_EVALUATOR_URL'] || ApiUrls::LIST_EVALUATOR_URL self.inbox_placement_root_url = ENV['ZEROBOUNCE_INBOX_PLACEMENT_URL'] || ApiUrls::INBOX_PLACEMENT_URL self.valid_statuses = %i[valid catch_all accept_all] diff --git a/lib/zerobounce/mock_request.rb b/lib/zerobounce/mock_request.rb index 9ad50da..e2e670f 100644 --- a/lib/zerobounce/mock_request.rb +++ b/lib/zerobounce/mock_request.rb @@ -10,7 +10,7 @@ class MockRequest < BaseRequest def self.get(path, params, content_type='application/json') response = self._get(Zerobounce.configuration.api_root_url, path, params, content_type) - if response.headers[:content_type] == 'application/json' + if __json_content_type__(response.headers[:content_type]) response_body = response.body response_body_json = JSON.parse(response_body) @@ -27,7 +27,7 @@ def self.get(path, params, content_type='application/json') def self.bulk_get(path, params, content_type='application/json') response = self._get(Zerobounce.configuration.bulk_api_root_url, path, params, content_type) - if response.headers[:content_type] == 'application/json' + if __json_content_type__(response.headers[:content_type]) response_body = response.body response_body_json = JSON.parse(response_body) return response_body_json unless response_body_json.is_a?(Hash) @@ -51,7 +51,7 @@ def self.bulk_getfile(path, params) def self.post(path, params, content_type='application/json', filepath=nil) response = self._post(Zerobounce.configuration.api_root_url, path, params, \ content_type, filepath) - if response.headers[:content_type] == 'application/json' + if __json_content_type__(response.headers[:content_type]) response_body = response.body response_body_json = JSON.parse(response_body) @@ -68,7 +68,7 @@ def self.post(path, params, content_type='application/json', filepath=nil) def self.bulk_post(path, params, content_type='application/json', filepath=nil) response = self._post(Zerobounce.configuration.bulk_api_root_url, path, params, \ content_type, filepath) - if response.headers[:content_type] == 'application/json' + if __json_content_type__(response.headers[:content_type]) return __parse_json_response__(response) end response.body @@ -76,7 +76,7 @@ def self.bulk_post(path, params, content_type='application/json', filepath=nil) def self.bulk_root_get(path, params, content_type='application/json') response = self._get(bulk_api_root_without_v2, path, params, content_type) - if response.headers[:content_type] == 'application/json' + if __json_content_type__(response.headers[:content_type]) return __parse_json_response__(response) end response.body @@ -89,7 +89,7 @@ def self.bulk_root_getfile(path, params) def self.bulk_root_post(path, params, content_type='application/json', filepath=nil) response = self._post(bulk_api_root_without_v2, path, params, content_type, filepath) - if response.headers[:content_type] == 'application/json' + if __json_content_type__(response.headers[:content_type]) return __parse_json_response__(response) end response.body diff --git a/lib/zerobounce/priority3.rb b/lib/zerobounce/priority3.rb index b9f39f0..a26ec2e 100644 --- a/lib/zerobounce/priority3.rb +++ b/lib/zerobounce/priority3.rb @@ -7,6 +7,12 @@ module Priority3 EMAIL_FINDER_BULK_BASE = 'email-finder' DOMAIN_SEARCH_BULK_BASE = 'domain-search' + # Inbox Placement Tester bounds, as documented and as the API enforces them. + INBOX_PLACEMENT_NAME_MAX = 64 + INBOX_PLACEMENT_TEST_TYPES = [1, 2, 3].freeze + INBOX_PLACEMENT_PAGE_SIZE_MIN = 1 + INBOX_PLACEMENT_PAGE_SIZE_MAX = 100 + def zb_request Zerobounce.class_variable_get(:@@request) end @@ -14,6 +20,11 @@ def zb_request def send_email_finder_file(filepath, domain_column:, first_name_column: nil, last_name_column: nil, middle_name_column: nil, full_name_column: nil, has_header_row: nil) raise ArgumentError, 'domain_column is required' if domain_column.nil? || domain_column.to_i < 1 + # One of the two name columns tells the finder what to look for; with + # neither there is nothing to build a local part from. + if __blank__(first_name_column) && __blank__(full_name_column) + raise ArgumentError, 'first_name_column or full_name_column is required' + end params = { domain_column: domain_column } params[:has_header_row] = has_header_row ? 'true' : 'false' unless has_header_row.nil? @@ -110,36 +121,33 @@ def list_s3_files(bucket: nil, search: nil) params = {} params[:bucket] = bucket unless bucket.nil? || bucket.to_s.empty? params[:search] = search unless search.nil? || search.to_s.empty? - zb_request.bulk_get('listS3files', params) + # The S3 listing lives on the bulkapi ROOT, like the jobs below it, not + # under /v2 - bulk_get would send it to /v2/listS3files, which is a 404. + zb_request.bulk_root_get('listS3files', params) end - def validate_s3_file(file_name:, **options) - raise ArgumentError, 'file_name is required' if file_name.nil? || file_name.to_s.empty? + # The same job shape as the four below, so it goes through the same helper: + # LIVE, the API rejects it without file_name and return_url. + def validate_s3_file(file_name:, has_header_row: false, **options) + bulk_root_file_job('validateS3file', file_name, has_header_row, **options) + end - params = { file_name: file_name } - options.each do |key, value| - next if value.nil? - params[key] = value.is_a?(TrueClass) || value.is_a?(FalseClass) ? (value ? 'true' : 'false') : value - end - zb_request.bulk_root_form_post('validateS3file', params) + def score_s3_file(file_name:, has_header_row: false, **options) + bulk_root_file_job('scoreS3file', file_name, has_header_row, **options) end - def score_s3_file(file_name:, **options) - raise ArgumentError, 'file_name is required' if file_name.nil? || file_name.to_s.empty? + # export_bucket is optional: omit it and the export goes to the bucket in + # your ZeroBounce settings, the way export_sftp_file's export_path does. + def export_s3_file(file_id:, export_bucket: nil) + raise ArgumentError, 'file_id is required' if file_id.nil? || file_id.to_s.strip.empty? - params = { file_name: file_name } - options.each do |key, value| - next if value.nil? - params[key] = value.is_a?(TrueClass) || value.is_a?(FalseClass) ? (value ? 'true' : 'false') : value + if !export_bucket.nil? && export_bucket.to_s.strip.empty? + raise ArgumentError, 'export_bucket is required' end - zb_request.bulk_root_form_post('scoreS3file', params) - end - - def export_s3_file(file_id:, export_bucket:) - raise ArgumentError, 'file_id is required' if file_id.nil? || file_id.to_s.empty? - raise ArgumentError, 'export_bucket is required' if export_bucket.nil? || export_bucket.to_s.empty? - zb_request.bulk_root_form_post('exportS3file', { file_id: file_id, export_bucket: export_bucket }) + params = { file_id: file_id } + params[:export_bucket] = export_bucket unless export_bucket.nil? + zb_request.bulk_root_form_post('exportS3file', params) end # The rest of the documented bulk surface. @@ -203,8 +211,20 @@ def inbox_placement_test_types zb_request.ipt_request(:get, 'test-types') end + # name is capped at 64 characters, testType is 1, 2 or 3, and a testType of + # 1 (the seed-list test) has nowhere to send from without senderEmail. All + # three are rejected by the API, so they are caught before a request that + # would spend inbox-test credits. def create_inbox_placement_test(name, **options) raise ArgumentError, 'name is required' if name.nil? || name.to_s.strip.empty? + raise ArgumentError, 'name must be 64 characters or fewer' if name.to_s.strip.length > INBOX_PLACEMENT_NAME_MAX + + if options.key?(:test_type) + test_type = __inbox_placement_test_type__(options[:test_type]) + if test_type == 1 && __blank__(options[:sender_email]) + raise ArgumentError, 'sender_email is required when test_type is 1' + end + end body = { 'name' => name } { @@ -226,8 +246,20 @@ def inbox_placement_test(test_id) def list_inbox_placement_tests(**options) params = {} params[:status] = options[:status] if options.key?(:status) + params[:createdAfter] = options[:created_after] if options.key?(:created_after) + params[:createdBefore] = options[:created_before] if options.key?(:created_before) params[:page] = options[:page] if options.key?(:page) - params[:pageSize] = options[:page_size] if options.key?(:page_size) + if options.key?(:page_size) + page_size = options[:page_size] + unless (INBOX_PLACEMENT_PAGE_SIZE_MIN..INBOX_PLACEMENT_PAGE_SIZE_MAX).cover?(page_size.to_i) + raise ArgumentError, + "page_size must be between #{INBOX_PLACEMENT_PAGE_SIZE_MIN} and #{INBOX_PLACEMENT_PAGE_SIZE_MAX}" + end + # LIVE: the page size parameter is page_size, not the camelCase + # pageSize the rest of this API uses. page_size=1 paginates; pageSize=1 + # is ignored and the whole list comes back. + params[:page_size] = page_size + end # LIVE: the listing is GET /tests (plural). /test is the single-test path # and answers 'Method "GET" not allowed'. zb_request.ipt_request(:get, 'tests', params) @@ -249,21 +281,34 @@ def set_inbox_placement_webhook(webhook_url) private - # POSTs one of the six documented S3 / SFTP file jobs on the bulkapi root. + # Nil, or a value that is nothing but whitespace. A column index of 0 is + # not blank: the API accepts it and the caller meant the first column. + def __blank__(value) + value.nil? || value.to_s.strip.empty? + end + + def __inbox_placement_test_type__(value) + test_type = value.is_a?(Integer) ? value : value.to_s.strip.to_i + unless INBOX_PLACEMENT_TEST_TYPES.include?(test_type) + raise ArgumentError, "test_type must be one of #{INBOX_PLACEMENT_TEST_TYPES.join(', ')}" + end + + test_type + end + + # POSTs one of the eight documented S3 / SFTP file jobs on the bulkapi root. # # Booleans go on the wire as the strings 'true'/'false'. # # LIVE: file_name and return_url are required on every one of these jobs, - # validateSFTPfile included, although the docs list file_name as optional - # for that one. Without either the API answers "This field is required". + # validateS3file, scoreS3file and validateSFTPfile included, although the + # docs list both as optional. Without either the API answers "This field is + # required". def bulk_root_file_job(path, file_name, has_header_row, **options) - if file_name.nil? || file_name.to_s.strip.empty? - raise ArgumentError, 'file_name is required' - end + raise ArgumentError, 'file_name is required' if __blank__(file_name) + raise ArgumentError, 'return_url is required' if __blank__(options[:return_url]) + return_url = options[:return_url] - if return_url.nil? || return_url.to_s.strip.empty? - raise ArgumentError, 'return_url is required' - end params = { has_header_row: has_header_row ? 'true' : 'false', diff --git a/lib/zerobounce/request.rb b/lib/zerobounce/request.rb index 785e101..8ba9855 100644 --- a/lib/zerobounce/request.rb +++ b/lib/zerobounce/request.rb @@ -20,7 +20,7 @@ def self.get(path, params, content_type='application/json') def self.bulk_get(path, params, content_type='application/json') response = self._get(Zerobounce.configuration.bulk_api_root_url, path, params, content_type) - if response.headers[:content_type] == 'application/json' + if __json_content_type__(response.headers[:content_type]) response_body = response.body response_body_json = JSON.parse(response_body) return response_body_json unless response_body_json.is_a?(Hash) @@ -64,7 +64,7 @@ def self.post(path, params, content_type='application/json', filepath=nil) def self.bulk_post(path, params, content_type='application/json', filepath=nil) response = self._post(Zerobounce.configuration.bulk_api_root_url, path, params, \ content_type, filepath) - if response.headers[:content_type] == 'application/json' + if __json_content_type__(response.headers[:content_type]) return __parse_json_response__(response) end return response.body @@ -72,7 +72,7 @@ def self.bulk_post(path, params, content_type='application/json', filepath=nil) def self.bulk_root_get(path, params, content_type='application/json') response = self._get(bulk_api_root_without_v2, path, params, content_type) - if response.headers[:content_type] == 'application/json' + if __json_content_type__(response.headers[:content_type]) return __parse_json_response__(response) end response.body @@ -85,7 +85,7 @@ def self.bulk_root_getfile(path, params) def self.bulk_root_post(path, params, content_type='application/json', filepath=nil) response = self._post(bulk_api_root_without_v2, path, params, content_type, filepath) - if response.headers[:content_type] == 'application/json' + if __json_content_type__(response.headers[:content_type]) return __parse_json_response__(response) end response.body diff --git a/live/live_api.rb b/live/live_api.rb index 4df7f39..cde7544 100644 --- a/live/live_api.rb +++ b/live/live_api.rb @@ -142,10 +142,25 @@ def validation_free end check('LIVE: S3/SFTP jobs reject a payload without return_url before sending') do - Zerobounce.email_finder_s3_file('f.csv', true) - 'unexpectedly succeeded' - rescue ArgumentError => e - e.message.include?('return_url') ? nil : "unexpected message: #{e.message}" + # validate_s3_file and score_s3_file share the same helper, so they are + # held to the same requirement the live API enforces. + %i[email_finder_s3_file domain_search_s3_file].each do |method| + begin + Zerobounce.public_send(method, 'f.csv', true) + return "#{method} unexpectedly succeeded" + rescue ArgumentError => e + return "#{method}: unexpected message: #{e.message}" unless e.message.include?('return_url') + end + end + %i[validate_s3_file score_s3_file].each do |method| + begin + Zerobounce.public_send(method, file_name: 'f.csv') + return "#{method} unexpectedly succeeded" + rescue ArgumentError => e + return "#{method}: unexpected message: #{e.message}" unless e.message.include?('return_url') + end + end + nil end check('LIVE: a complete S3 job payload is accepted by the API') do diff --git a/spec/base_request_spec.rb b/spec/base_request_spec.rb index 96d5d73..3cea307 100644 --- a/spec/base_request_spec.rb +++ b/spec/base_request_spec.rb @@ -20,6 +20,28 @@ end end + describe '.__json_content_type__' do + let(:json_content_type) { described_class.method(:__json_content_type__) } + + it 'matches the media type, ignoring parameters and case' do + ['application/json', + 'application/json; charset=utf-8', + 'application/json;charset=UTF-8', + 'APPLICATION/JSON', + ' application/json ', + 'application/problem+json; charset=utf-8'].each do |content_type| + expect(json_content_type.call(content_type)).to be(true), content_type + end + end + + it 'does not match other media types or a missing header' do + ['text/csv', 'text/html; charset=utf-8', 'application/octet-stream', + 'application/jsonp', '', nil].each do |content_type| + expect(json_content_type.call(content_type)).to be(false), content_type.inspect + end + end + end + describe '.__root_without_trailing_slashes__' do it 'returns the string unchanged when there is no trailing slash' do expect(strip_trailing_slashes.call('https://api.example.com')).to eq('https://api.example.com') diff --git a/spec/bulk_jobs_spec.rb b/spec/bulk_jobs_spec.rb index 1fe436b..00874af 100644 --- a/spec/bulk_jobs_spec.rb +++ b/spec/bulk_jobs_spec.rb @@ -263,16 +263,57 @@ def last_query end it 'sends only the list filters that are given' do - Zerobounce.list_inbox_placement_tests(status: 'finished', page: 1, page_size: 25) + Zerobounce.list_inbox_placement_tests(status: 'finished', page: 1, page_size: 25, + created_after: '2026-01-01', created_before: '2026-02-01') # LIVE: the listing is GET /tests (plural); /test answers # 'Method "GET" not allowed'. expect(last_request.uri.path).to eq('/api/ipt/tests') - expect(last_query).to include('status' => 'finished', 'page' => '1', 'pageSize' => '25') + # LIVE: the page size parameter is page_size. pageSize is accepted and + # then ignored - page_size=1 paginates, pageSize=1 returns the full list. + expect(last_query).to include('status' => 'finished', 'page' => '1', 'page_size' => '25', + 'createdAfter' => '2026-01-01', 'createdBefore' => '2026-02-01') + expect(last_query).not_to have_key('pageSize') Zerobounce.list_inbox_placement_tests expect(last_query.keys).to eq([]) end + it 'bounds page_size to 1-100 before any request' do + [0, -1, 101, 'many'].each do |page_size| + expect { Zerobounce.list_inbox_placement_tests(page_size: page_size) } + .to raise_error(ArgumentError, /page_size must be between 1 and 100/) + end + expect(WebMock).not_to have_requested(:any, %r{zerobounce\.net}) + + [1, 100].each do |page_size| + Zerobounce.list_inbox_placement_tests(page_size: page_size) + expect(last_query).to include('page_size' => page_size.to_s) + end + end + + it 'enforces the documented bounds on a new test before it spends credits' do + expect { Zerobounce.create_inbox_placement_test('n' * 65) } + .to raise_error(ArgumentError, /64 characters/) + expect { Zerobounce.create_inbox_placement_test('Launch', test_type: 4) } + .to raise_error(ArgumentError, /test_type must be one of 1, 2, 3/) + expect { Zerobounce.create_inbox_placement_test('Launch', test_type: 0) } + .to raise_error(ArgumentError, /test_type must be one of 1, 2, 3/) + # testType 1 is the seed-list test, which has nowhere to send from + # without a sender address. + expect { Zerobounce.create_inbox_placement_test('Launch', test_type: 1) } + .to raise_error(ArgumentError, /sender_email is required when test_type is 1/) + expect { Zerobounce.create_inbox_placement_test('Launch', test_type: 1, sender_email: ' ') } + .to raise_error(ArgumentError, /sender_email is required when test_type is 1/) + + expect(WebMock).not_to have_requested(:any, %r{zerobounce\.net}) + + # A name of exactly 64 and the test types that need no sender are fine. + Zerobounce.create_inbox_placement_test('n' * 64, test_type: 2) + expect(JSON.parse(last_request.body)).to eq('name' => 'n' * 64, 'testType' => 2) + Zerobounce.create_inbox_placement_test('Launch', test_type: 3) + expect(JSON.parse(last_request.body)).to eq('name' => 'Launch', 'testType' => 3) + end + it 'deletes a test and sets the webhook' do Zerobounce.delete_inbox_placement_test('a b/c') expect(last_request.method).to eq(:delete) diff --git a/spec/fixtures/priority3_finder.csv b/spec/fixtures/priority3_finder.csv index ffb0271..68d870c 100644 --- a/spec/fixtures/priority3_finder.csv +++ b/spec/fixtures/priority3_finder.csv @@ -1,2 +1,2 @@ -domain,name -acme.com,Jane +domain,first,full +acme.com,Jane,Jane Doe diff --git a/spec/priority3_spec.rb b/spec/priority3_spec.rb index 8cebed0..079505e 100644 --- a/spec/priority3_spec.rb +++ b/spec/priority3_spec.rb @@ -10,44 +10,355 @@ Zerobounce.configure { |c| c.apikey = api_key } end - it 'send_email_finder_file posts to bulk email-finder sendfile' do - fixture = File.join(Dir.pwd, 'spec', 'fixtures', 'priority3_finder.csv') - FileUtils.mkdir_p(File.dirname(fixture)) - File.write(fixture, "domain,name\nacme.com,Jane\n") - - stub_request(:post, %r{\Ahttps://bulkapi\.zerobounce\.net/email-finder/sendfile}) - .to_return( - status: 200, - body: '{"success":true,"file_id":"f1","file_name":"out.csv"}', - headers: { 'Content-Type' => 'application/json' } - ) + after do + Zerobounce.config.bulk_root_url = nil + end - result = Zerobounce.send_email_finder_file(fixture, domain_column: 1) - expect(result['success']).to be true + def last_request + WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last end - it 'validate_s3_file posts to bulk validateS3file' do - stub_request(:post, %r{\Ahttps://bulkapi\.zerobounce\.net/validateS3file}) - .to_return( - status: 200, - body: '{"success":true}', - headers: { 'Content-Type' => 'application/json' } - ) + def last_form + URI.decode_www_form(last_request.body.to_s).to_h + end + + def last_query + URI.decode_www_form(last_request.uri.query.to_s).to_h + end + + describe '.send_email_finder_file' do + let(:fixture) { File.join(Dir.pwd, 'spec', 'fixtures', 'priority3_finder.csv') } + + before do + FileUtils.mkdir_p(File.dirname(fixture)) + File.write(fixture, "domain,first,full\nacme.com,Jane,Jane Doe\n") + end - result = Zerobounce.validate_s3_file(file_name: 'bucket/key.csv') - expect(result['success']).to be true + it 'posts to the bulk email-finder sendfile on the bulkapi root' do + stub_request(:post, 'https://bulkapi.zerobounce.net/email-finder/sendfile') + .to_return( + status: 200, + body: '{"success":true,"file_id":"f1","file_name":"out.csv"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = Zerobounce.send_email_finder_file(fixture, domain_column: 1, first_name_column: 2) + expect(last_request.method).to eq(:post) + expect(last_request.uri.host).to eq('bulkapi.zerobounce.net') + expect(last_request.uri.path).to eq('/email-finder/sendfile') + expect(last_request.uri.query).to be_nil + # A multipart upload carries the key in the body, not the query string. + expect(last_request.body).to include('name="api_key"', api_key) + expect(last_request.body).to include('name="domain_column"', 'name="first_name_column"') + expect(result['success']).to be true + expect(result['file_id']).to eq('f1') + end + + it 'accepts full_name_column instead of first_name_column' do + stub_request(:post, 'https://bulkapi.zerobounce.net/email-finder/sendfile') + .to_return(status: 200, body: '{"success":true}', + headers: { 'Content-Type' => 'application/json' }) + + Zerobounce.send_email_finder_file(fixture, domain_column: 1, full_name_column: 3) + expect(last_request.body).to include('name="full_name_column"') + expect(last_request.body).not_to include('name="first_name_column"') + end + + it 'requires one of first_name_column or full_name_column before any request' do + # The finder has nothing to build a local part from without a name column. + expect { Zerobounce.send_email_finder_file(fixture, domain_column: 1) } + .to raise_error(ArgumentError, /first_name_column or full_name_column/) + expect { Zerobounce.send_email_finder_file(fixture, domain_column: 1, first_name_column: ' ') } + .to raise_error(ArgumentError, /first_name_column or full_name_column/) + + expect(WebMock).not_to have_requested(:any, /zerobounce\.net/) + end + + it 'raises the error body the API sends back' do + stub_request(:post, 'https://bulkapi.zerobounce.net/email-finder/sendfile') + .to_return(status: 200, body: '{"success":false,"message":"Invalid API key."}', + headers: { 'Content-Type' => 'application/json; charset=utf-8' }) + + expect { Zerobounce.send_email_finder_file(fixture, domain_column: 1, first_name_column: 2) } + .to raise_error(/Invalid API key\./) + end end - it 'list_s3_files gets bulk listS3files' do - stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/v2/listS3files}) - .to_return( - status: 200, - body: '[{"file_name":"bucket/key.csv"}]', - headers: { 'Content-Type' => 'application/json' } + describe '.list_s3_files' do + it 'gets listS3files on the bulkapi root, without /v2' do + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/listS3files\?}) + .to_return( + status: 200, + body: '[{"file_name":"bucket/key.csv"}]', + headers: { 'Content-Type' => 'application/json' } + ) + + result = Zerobounce.list_s3_files(bucket: 'my-bucket', search: 'key') + expect(last_request.method).to eq(:get) + expect(last_request.uri.path).to eq('/listS3files') + expect(last_query).to include('api_key' => api_key, 'bucket' => 'my-bucket', 'search' => 'key') + expect(result).to be_an(Array) + expect(result.first['file_name']).to eq('bucket/key.csv') + end + + it 'leaves bucket and search off when they are not given' do + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/listS3files\?}) + .to_return(status: 200, body: '[]', headers: { 'Content-Type' => 'application/json' }) + + expect(Zerobounce.list_s3_files).to eq([]) + expect(last_query.keys).to eq(['api_key']) + end + + it 'raises an error body instead of returning it' do + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/listS3files\?}) + .to_return(status: 200, body: '{"error":"Invalid API key."}', + headers: { 'Content-Type' => 'application/json' }) + + expect { Zerobounce.list_s3_files }.to raise_error(/Invalid API key\./) + end + end + + describe '.validate_s3_file and .score_s3_file' do + before do + stub_request(:post, %r{\Ahttps://bulkapi\.zerobounce\.net/(validate|score)S3file}) + .to_return(status: 200, body: '{"success":true,"file_id":"fid"}', + headers: { 'Content-Type' => 'application/json' }) + end + + it 'posts the same job shape as the other S3 and SFTP jobs' do + result = Zerobounce.validate_s3_file(file_name: 'bucket/key.csv', has_header_row: true, + return_url: 'https://example.com/done', + remove_duplicates: true) + expect(last_request.method).to eq(:post) + expect(last_request.uri.host).to eq('bulkapi.zerobounce.net') + expect(last_request.uri.path).to eq('/validateS3file') + # The key travels in the form body, not a query string that gets logged. + expect(last_request.uri.query).to be_nil + # A form POST carries the key in the body, not the query string. + expect(last_form).to include( + 'api_key' => api_key, + 'file_name' => 'bucket/key.csv', + 'has_header_row' => 'true', + 'remove_duplicates' => 'true', + 'return_url' => 'https://example.com/done' ) + expect(result['file_id']).to eq('fid') + end + + it 'posts scoreS3file to its own path and leaves unset options off' do + Zerobounce.score_s3_file(file_name: 'bucket/key.csv', return_url: 'https://example.com/done') + expect(last_request.uri.path).to eq('/scoreS3file') + expect(last_form).to include('has_header_row' => 'false') + expect(last_form).not_to have_key('remove_duplicates') + end + + it 'requires file_name and return_url before any request' do + # LIVE: the API answers "This field is required" without either, the same + # as it does for the S3 and SFTP jobs these now share a helper with. + %i[validate_s3_file score_s3_file].each do |method| + expect { Zerobounce.public_send(method, file_name: ' ', return_url: 'https://example.com/done') } + .to raise_error(ArgumentError, /file_name/) + expect { Zerobounce.public_send(method, file_name: 'bucket/key.csv') } + .to raise_error(ArgumentError, /return_url/) + expect { Zerobounce.public_send(method, file_name: 'bucket/key.csv', return_url: ' ') } + .to raise_error(ArgumentError, /return_url/) + end + + expect(WebMock).not_to have_requested(:any, /zerobounce\.net/) + end + end + + describe '.export_s3_file' do + before do + stub_request(:post, 'https://bulkapi.zerobounce.net/exportS3file') + .to_return(status: 200, body: '{"success":true}', + headers: { 'Content-Type' => 'application/json' }) + end + + it 'sends export_bucket when it is given' do + Zerobounce.export_s3_file(file_id: 'fid', export_bucket: 'my-bucket') + expect(last_request.method).to eq(:post) + expect(last_request.uri.host).to eq('bulkapi.zerobounce.net') + expect(last_request.uri.path).to eq('/exportS3file') + expect(last_form).to include('api_key' => api_key, 'file_id' => 'fid', 'export_bucket' => 'my-bucket') + end + + it 'leaves export_bucket out when it is absent, falling back to account settings' do + result = Zerobounce.export_s3_file(file_id: 'fid') + expect(last_form).to include('file_id' => 'fid') + expect(last_form).not_to have_key('export_bucket') + expect(result['success']).to be true + end + + it 'rejects a blank file_id or a blank export_bucket before any request' do + expect { Zerobounce.export_s3_file(file_id: ' ') }.to raise_error(ArgumentError, /file_id/) + expect { Zerobounce.export_s3_file(file_id: 'fid', export_bucket: ' ') } + .to raise_error(ArgumentError, /export_bucket/) + + expect(WebMock).not_to have_requested(:any, /zerobounce\.net/) + end + end + + describe 'a JSON content type with parameters' do + # The API types some responses 'application/json; charset=utf-8'. Compared + # with ==, those skipped JSON parsing and an error payload came back as a + # plain String that read as a success. + it 'is parsed as JSON on the bulk /v2 endpoints' do + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/v2/filestatus}) + .to_return(status: 200, body: '{"success":true,"file_id":"fid","complete_percentage":"100%"}', + headers: { 'Content-Type' => 'application/json; charset=utf-8' }) + + result = Zerobounce.validate_file_check('fid') + expect(result).to be_a(Hash) + expect(result['complete_percentage']).to eq('100%') + end + + it 'raises a bulk /v2 error body instead of returning it as a String' do + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/v2/filestatus}) + .to_return(status: 200, body: '{"success":false,"message":"File cannot be found."}', + headers: { 'Content-Type' => 'application/json; charset=utf-8' }) + + expect { Zerobounce.validate_file_check('fid') }.to raise_error(/File cannot be found/) + end + + it 'raises a bulkapi root error body instead of returning it as a String' do + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/email-finder/filestatus\?}) + .to_return(status: 200, body: '{"error":"File cannot be found."}', + headers: { 'Content-Type' => 'APPLICATION/JSON; charset=UTF-8' }) + + expect { Zerobounce.email_finder_file_status('fid') }.to raise_error(/File cannot be found/) + end + end + + describe 'bulk_root_url configuration' do + it 'is derived from bulk_api_root_url when it is not set' do + expect(Zerobounce.config.bulk_root_url).to be_nil + + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/listS3files\?}) + .to_return(status: 200, body: '[]', headers: { 'Content-Type' => 'application/json' }) + + Zerobounce.list_s3_files + expect(last_request.uri.host).to eq('bulkapi.zerobounce.net') + expect(last_request.uri.path).to eq('/listS3files') + end + + it 'points the S3 and SFTP jobs elsewhere when it is set' do + Zerobounce.config.bulk_root_url = 'https://bulk-mock.example.com/' + + stub_request(:get, %r{\Ahttps://bulk-mock\.example\.com/listS3files\?}) + .to_return(status: 200, body: '[]', headers: { 'Content-Type' => 'application/json' }) + stub_request(:post, 'https://bulk-mock.example.com/exportS3file') + .to_return(status: 200, body: '{"success":true}', + headers: { 'Content-Type' => 'application/json' }) + + Zerobounce.list_s3_files + expect(last_request.uri.host).to eq('bulk-mock.example.com') + expect(last_request.uri.path).to eq('/listS3files') + + Zerobounce.export_s3_file(file_id: 'fid') + expect(last_request.uri.host).to eq('bulk-mock.example.com') + expect(last_request.uri.path).to eq('/exportS3file') + end + + it 'leaves the /v2 bulk endpoints alone' do + Zerobounce.config.bulk_root_url = 'https://bulk-mock.example.com/' + + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/v2/filestatus}) + .to_return(status: 200, body: '{"success":true,"file_id":"fid"}', + headers: { 'Content-Type' => 'application/json' }) + + Zerobounce.validate_file_check('fid') + expect(last_request.uri.host).to eq('bulkapi.zerobounce.net') + expect(last_request.uri.path).to eq('/v2/filestatus') + end + end + + describe 'the email finder (GET /guessformat)' do + before do + stub_request(:get, %r{\Ahttps://api\.zerobounce\.net/v2/guessformat}) + .to_return( + status: 200, + body: '{"email":"john@zerobounce.net","email_confidence":"medium",' \ + '"domain":"zerobounce.net","company_name":"ZeroBounce",' \ + '"did_you_mean":"","failure_reason":""}', + headers: { 'Content-Type' => 'application/json; charset=utf-8' } + ) + end + + describe '.find_email' do + it 'sends first_name and the names that are given' do + result = Zerobounce.find_email('John', domain: 'zerobounce.net', middle_name: 'Deere', last_name: 'Doe') + expect(last_request.method).to eq(:get) + expect(last_request.uri.path).to eq('/v2/guessformat') + expect(last_query).to eq( + 'api_key' => api_key, + 'first_name' => 'John', + 'domain' => 'zerobounce.net', + 'middle_name' => 'Deere', + 'last_name' => 'Doe' + ) + expect(result['email']).to eq('john@zerobounce.net') + end + + it 'works without a first name' do + # The endpoint only requires a domain or a company name; a first name + # narrows the answer to one person but is not required. + Zerobounce.find_email(domain: 'zerobounce.net') + expect(last_query).to eq('api_key' => api_key, 'domain' => 'zerobounce.net') + + Zerobounce.find_email(nil, company_name: 'Zero Bounce', last_name: 'Doe') + expect(last_query).to eq('api_key' => api_key, 'company_name' => 'Zero Bounce', 'last_name' => 'Doe') + end + + it 'still requires exactly one of domain or company_name' do + expect { Zerobounce.find_email } + .to raise_error(ArgumentError, /Either domain or company_name must be provided/) + expect { Zerobounce.find_email('John') } + .to raise_error(ArgumentError, /Either domain or company_name must be provided/) + expect { Zerobounce.find_email(domain: 'zerobounce.net', company_name: 'Zero Bounce') } + .to raise_error(ArgumentError, /Only one of domain or company_name can be provided/) + + expect(WebMock).not_to have_requested(:any, /zerobounce\.net/) + end + + it 'raises the error body the API sends back' do + stub_request(:get, %r{\Ahttps://api\.zerobounce\.net/v2/guessformat}) + .to_return(status: 200, body: '{"error":"Invalid API key or your account ran out of credits"}', + headers: { 'Content-Type' => 'application/json; charset=utf-8' }) + + expect { Zerobounce.find_email(domain: 'zerobounce.net') } + .to raise_error(/Invalid API key or your account ran out of credits/) + end + end + + describe '.find_domain' do + it 'sends no name fields at all' do + Zerobounce.find_domain(domain: 'zerobounce.net') + expect(last_query).to eq('api_key' => api_key, 'domain' => 'zerobounce.net') + + Zerobounce.find_domain(company_name: 'Zero Bounce') + expect(last_query).to eq('api_key' => api_key, 'company_name' => 'Zero Bounce') + end + + it 'still requires exactly one of domain or company_name' do + expect { Zerobounce.find_domain } + .to raise_error(ArgumentError, /Either domain or company_name must be provided/) + expect { Zerobounce.find_domain(domain: 'zerobounce.net', company_name: 'Zero Bounce') } + .to raise_error(ArgumentError, /Only one of domain or company_name can be provided/) + end + end - result = Zerobounce.list_s3_files(bucket: 'my-bucket', search: 'key') - expect(result).to be_an(Array) - expect(result.first['file_name']).to eq('bucket/key.csv') + describe '.guessformat' do + it 'keeps its deprecated positional domain and name options' do + expect { Zerobounce.guessformat('zerobounce.net', first_name: 'John', last_name: 'Doe') } + .to output(/deprecated/).to_stderr + expect(last_query).to eq( + 'api_key' => api_key, + 'domain' => 'zerobounce.net', + 'first_name' => 'John', + 'last_name' => 'Doe' + ) + end + end end end