zerobounce-sdk 1.1.1 → 2.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/zerobounce.rb CHANGED
@@ -2,19 +2,23 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'date'
5
+ require 'tempfile'
5
6
 
6
7
  require 'zerobounce/error'
7
8
  require 'zerobounce/version'
9
+ require 'zerobounce/download_type'
10
+ require 'zerobounce/get_file_options'
11
+ require 'zerobounce/get_file_helper'
12
+ require 'zerobounce/validate_status'
13
+ require 'zerobounce/validate_sub_status'
8
14
  require 'zerobounce/request'
9
15
  require 'zerobounce/mock_request'
10
16
  require 'zerobounce/configuration'
17
+ require 'zerobounce/api_urls'
11
18
 
12
19
  # Validate an email address with Zerobounce.net
13
20
  module Zerobounce
14
21
 
15
- API_ROOT_URL = 'https://api.zerobounce.net/v2'
16
- BULK_API_ROOT_URL = 'https://bulkapi.zerobounce.net/v2'
17
-
18
22
  class << self
19
23
  attr_writer :configuration
20
24
 
@@ -28,6 +32,14 @@ module Zerobounce
28
32
  end
29
33
  alias config configuration
30
34
 
35
+ # Whether a getfile response body looks like a JSON error payload (including HTTP 200).
36
+ #
37
+ # @param [String] body
38
+ # @return [Boolean]
39
+ def get_file_json_indicates_error?(body)
40
+ GetFileHelper.json_indicates_error?(body)
41
+ end
42
+
31
43
  # Configure Zerobounce inside a block.
32
44
  #
33
45
  # @example
@@ -56,6 +68,7 @@ module Zerobounce
56
68
  # "domain": null,
57
69
  # "domain_age_days": "9692",
58
70
  # "smtp_provider": "example",
71
+ # "catchall_domain": false,
59
72
  # "mx_found": "true",
60
73
  # "mx_record": "mx.example.com",
61
74
  # "firstname": "zero",
@@ -135,10 +148,13 @@ module Zerobounce
135
148
  # "sub_status_mailbox_quota_exceeded": 0,
136
149
  # "sub_status_forcible_disconnect": 0,
137
150
  # "sub_status_failed_smtp_connection": 0,
151
+ # "sub_status_accept_all": 0,
138
152
  # "sub_status_mx_forward": 0,
139
153
  # "sub_status_alternate": 0,
140
- # "sub_status_blocked": 0,
141
154
  # "sub_status_allowed": 0,
155
+ # "sub_status_blocked": 0,
156
+ # "sub_status_gold": 0,
157
+ # "sub_status_role_based_accept_all": 0,
142
158
  # "start_date": "1/1/2018",
143
159
  # "end_date": "12/12/2023"
144
160
  # }
@@ -184,6 +200,7 @@ module Zerobounce
184
200
  # "domain": null,
185
201
  # "domain_age_days": "9692",
186
202
  # "smtp_provider": "example",
203
+ # "catchall_domain": false,
187
204
  # "mx_found": "true",
188
205
  # "mx_record": "mx.example.com",
189
206
  # "firstname": "zero",
@@ -215,7 +232,7 @@ module Zerobounce
215
232
  })
216
233
  end
217
234
  params = {email_batch: email_batch}
218
- results = @@request.bulk_post('validatebatch', params)
235
+ results = @@request.post('validatebatch', params)
219
236
  return results['email_batch']
220
237
  end
221
238
 
@@ -243,7 +260,8 @@ module Zerobounce
243
260
  last_name_column: 3,
244
261
  gender_column: 4,
245
262
  has_header_row: true,
246
- return_url: nil
263
+ return_url: nil,
264
+ allow_phase_2: nil
247
265
  )
248
266
  params = {
249
267
  email_address_column: email_address_column,
@@ -253,9 +271,34 @@ module Zerobounce
253
271
  params[:last_name_column] = last_name_column if last_name_column
254
272
  params[:gender_column] = gender_column if gender_column
255
273
  params[:return_url] = return_url if return_url
274
+ unless allow_phase_2.nil?
275
+ params[:allow_phase_2] = allow_phase_2 ? 'true' : 'false'
276
+ end
256
277
  @@request.bulk_post('sendfile', params, 'multipart/form-data', filepath)
257
278
  end
258
279
 
280
+ # Validate CSV from a stream (IO or String).
281
+ #
282
+ # @param [IO, String] io Stream or string content to upload
283
+ # @param [String] file_name Filename for the upload (e.g. "emails.csv")
284
+ # @option [Int] :email_address_column (same as validate_file_send)
285
+ # @option [Int] :first_name_column
286
+ # @option [Int] :last_name_column
287
+ # @option [Int] :gender_column
288
+ # @option [Int] :has_header_row
289
+ # @option [Int] :return_url
290
+ # @option [Boolean] :allow_phase_2 When set, sends allow_phase_2 (validation bulk only).
291
+ # @return [Hash] same as validate_file_send
292
+ def validate_file_send_stream(io, file_name, **opts)
293
+ content = io.respond_to?(:read) ? io.read : io.to_s
294
+ Tempfile.create(['zb', File.extname(file_name)]) do |tmp|
295
+ tmp.binmode
296
+ tmp.write(content)
297
+ tmp.rewind
298
+ validate_file_send(tmp.path, **opts)
299
+ end
300
+ end
301
+
259
302
  # Get validate file status
260
303
  #
261
304
  # @param [String] :file_id Id of the file.
@@ -279,13 +322,20 @@ module Zerobounce
279
322
 
280
323
  # Get validate results file
281
324
  #
282
- # @param [String] :file_id Id of the file.
283
- #
284
- # @return [String/File?]
285
- def validate_file_get(file_id)
286
- # todo:
287
- params = {file_id: file_id}
288
- @@request.bulk_get('getfile', params)
325
+ # @param [String] file_id Id of the file.
326
+ # @param [Zerobounce::GetFileOptions, nil] options Optional +download_type+ and +activity_data+ (validation bulk only).
327
+ #
328
+ # @return [String] file body on success
329
+ # @raise [RuntimeError] on API/JSON error responses
330
+ def validate_file_get(file_id, options = nil)
331
+ params = { file_id: file_id }
332
+ if options
333
+ params[:download_type] = options.download_type if options.download_type
334
+ unless options.activity_data.nil?
335
+ params[:activity_data] = options.activity_data ? 'true' : 'false'
336
+ end
337
+ end
338
+ @@request.bulk_getfile('getfile', params)
289
339
  end
290
340
 
291
341
  # Delete validate file
@@ -333,15 +383,37 @@ module Zerobounce
333
383
  @@request.bulk_post('scoring/sendfile', params, 'multipart/form-data', filepath)
334
384
  end
335
385
 
336
- # Get validate results file
386
+ # Score CSV from a stream (IO or String).
387
+ #
388
+ # @param [IO, String] io Stream or string content to upload
389
+ # @param [String] file_name Filename for the upload (e.g. "emails.csv")
390
+ # @option [Int] :email_address_column (same as scoring_file_send)
391
+ # @option [Int] :has_header_row
392
+ # @option [Int] :return_url
393
+ # @return [Hash] same as scoring_file_send
394
+ def scoring_file_send_stream(io, file_name, **opts)
395
+ content = io.respond_to?(:read) ? io.read : io.to_s
396
+ Tempfile.create(['zb', File.extname(file_name)]) do |tmp|
397
+ tmp.binmode
398
+ tmp.write(content)
399
+ tmp.rewind
400
+ scoring_file_send(tmp.path, **opts)
401
+ end
402
+ end
403
+
404
+ # Get scoring bulk results file
337
405
  #
338
- # @param [String] :file_id Id of the file.
406
+ # @param [String] file_id Id of the file.
407
+ # @param [Zerobounce::GetFileOptions, nil] options Optional +download_type+; +activity_data+ is not sent.
339
408
  #
340
- # @return [String/File?]
341
- def scoring_file_get(file_id)
342
- # todo:
343
- params = {file_id: file_id}
344
- @@request.bulk_get('scoring/getfile', params)
409
+ # @return [String] file body on success
410
+ # @raise [RuntimeError] on API/JSON error responses
411
+ def scoring_file_get(file_id, options = nil)
412
+ params = { file_id: file_id }
413
+ if options && options.download_type
414
+ params[:download_type] = options.download_type
415
+ end
416
+ @@request.bulk_getfile('scoring/getfile', params)
345
417
  end
346
418
 
347
419
  # Get validate file status
@@ -427,19 +499,120 @@ module Zerobounce
427
499
  # ]
428
500
  # }
429
501
  def guessformat(domain, first_name: '', middle_name: '', last_name: '')
430
- params = {
431
- domain: domain
432
- }
433
- unless first_name.empty?
434
- params['first_name'] = first_name
435
- end
436
- unless middle_name.empty?
437
- params['middle_name'] = middle_name
502
+ warn "[DEPRECATION] `guessformat` is deprecated and will be removed in a future version.\n" \
503
+ "Please use `find_email` or `find_domain` instead."
504
+ params = {domain: domain}
505
+ params[:first_name] = first_name unless first_name.nil? || first_name.empty?
506
+ params[:middle_name] = middle_name unless middle_name.nil? || middle_name.empty?
507
+ params[:last_name] = last_name unless last_name.nil? || last_name.empty?
508
+ @@request.get('guessformat', params)
509
+ end
510
+
511
+
512
+ # Find email address format
513
+ #
514
+ # @option [String] domain Domain to search within (e.g. example.com). Required if company_name is not provided.
515
+ # @option [String] company_name Company name to search within (e.g. Example). Required if domain is not provided.
516
+ # @option [String] first_name First name of target.
517
+ # @option [String] middle_name Middle name of target.
518
+ # @option [String] last_name Last name of target.
519
+ #
520
+ # @return [Hash]
521
+ # {
522
+ # "email": "john@zerobounce.net",
523
+ # "email_confidence": "medium",
524
+ # "domain": "zerobounce.net",
525
+ # "company_name": "ZeroBounce",
526
+ # "did_you_mean": "",
527
+ # "failure_reason": ""
528
+ # }
529
+ def find_email(first_name, domain: '', company_name: '', middle_name: '', last_name: '')
530
+ # Validate that exactly one of domain or company_name is provided
531
+ if (domain.nil? || domain.empty?) && (company_name.nil? || company_name.empty?)
532
+ raise ArgumentError, "Either domain or company_name must be provided"
533
+ elsif !(domain.nil? || domain.empty?) && !(company_name.nil? || company_name.empty?)
534
+ raise ArgumentError, "Only one of domain or company_name can be provided"
438
535
  end
439
- unless last_name.empty?
440
- params['last_name'] = last_name
536
+
537
+ params = { first_name: first_name }
538
+ params[:domain] = domain unless domain.nil? || domain.empty?
539
+ params[:company_name] = company_name unless company_name.nil? || company_name.empty?
540
+ params[:middle_name] = middle_name unless middle_name.nil? || middle_name.empty?
541
+ params[:last_name] = last_name unless last_name.nil? || last_name.empty?
542
+
543
+ @@request.get('guessformat', params)
544
+ end
545
+
546
+ # Find domain format
547
+ #
548
+ # @option [String] domain Domain to search within (e.g. example.com). Required if company_name is not provided.
549
+ # @option [String] company_name Company name to search within (e.g. Example). Required if domain is not provided.
550
+ #
551
+ # @return [Hash]
552
+ # {
553
+ # "domain": "zerobounce.net",
554
+ # "company_name": "Hertza, LLC",
555
+ # "format": "first.last",
556
+ # "confidence": "high",
557
+ # "did_you_mean": "",
558
+ # "failure_reason": "",
559
+ # "other_domain_formats": [
560
+ # {"format": "first", "confidence": "high"},
561
+ # {"format": "last.first", "confidence": "high"},
562
+ # {"format": "lastfirst", "confidence": "high"},
563
+ # {"format": "firstl", "confidence": "high"},
564
+ # {"format": "lfirst", "confidence": "high"},
565
+ # {"format": "firstlast", "confidence": "high"},
566
+ # {"format": "last_middle_f", "confidence": "high"},
567
+ # {"format": "last", "confidence": "high"},
568
+ # {"format": "f.last", "confidence": "medium"},
569
+ # {"format": "last-f", "confidence": "medium"},
570
+ # {"format": "l.first", "confidence": "medium"},
571
+ # {"format": "last_f", "confidence": "medium"},
572
+ # {"format": "first.middle.last", "confidence": "medium"},
573
+ # {"format": "first-last", "confidence": "medium"},
574
+ # {"format": "last.f", "confidence": "medium"},
575
+ # {"format": "last_first", "confidence": "medium"},
576
+ # {"format": "f-last", "confidence": "medium"},
577
+ # {"format": "first.l", "confidence": "medium"},
578
+ # {"format": "first-l", "confidence": "medium"},
579
+ # {"format": "first_l", "confidence": "medium"},
580
+ # {"format": "first_last", "confidence": "medium"},
581
+ # {"format": "f_last", "confidence": "medium"},
582
+ # {"format": "last-first", "confidence": "medium"},
583
+ # {"format": "flast", "confidence": "medium"},
584
+ # {"format": "lastf", "confidence": "medium"},
585
+ # {"format": "l_first", "confidence": "medium"},
586
+ # {"format": "l-first", "confidence": "medium"},
587
+ # {"format": "first-middle-last", "confidence": "low"},
588
+ # {"format": "firstmlast", "confidence": "low"},
589
+ # {"format": "last.middle.first", "confidence": "low"},
590
+ # {"format": "last_middle_first", "confidence": "low"},
591
+ # {"format": "first_middle_last", "confidence": "low"},
592
+ # {"format": "last-middle-first", "confidence": "low"},
593
+ # {"format": "first-m-last", "confidence": "low"},
594
+ # {"format": "firstmiddlelast", "confidence": "low"},
595
+ # {"format": "last.m.first", "confidence": "low"},
596
+ # {"format": "lastmfirst", "confidence": "low"},
597
+ # {"format": "lastmiddlefirst", "confidence": "low"},
598
+ # {"format": "last_m_first", "confidence": "low"},
599
+ # {"format": "first.m.last", "confidence": "low"},
600
+ # {"format": "first_m_last", "confidence": "low"},
601
+ # {"format": "last-m-first", "confidence": "low"}
602
+ # ]
603
+ # }
604
+ def find_domain(domain: '', company_name: '')
605
+ # Validate that exactly one of domain or company_name is provided
606
+ if (domain.nil? || domain.empty?) && (company_name.nil? || company_name.empty?)
607
+ raise ArgumentError, "Either domain or company_name must be provided"
608
+ elsif !(domain.nil? || domain.empty?) && !(company_name.nil? || company_name.empty?)
609
+ raise ArgumentError, "Only one of domain or company_name can be provided"
441
610
  end
442
611
 
612
+ params = {}
613
+ params[:domain] = domain unless domain.nil? || domain.empty?
614
+ params[:company_name] = company_name unless company_name.nil? || company_name.empty?
615
+
443
616
  @@request.get('guessformat', params)
444
617
  end
445
618
 
data/zerobounce.gemspec CHANGED
@@ -27,8 +27,12 @@ Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength
27
27
  spec.required_ruby_version = '>= 2.5'
28
28
 
29
29
  spec.add_dependency 'rest-client', '~>2.1'
30
- spec.add_dependency 'dotenv', '~> 2.8.1'
30
+ spec.add_dependency 'dotenv'
31
31
 
32
+ spec.add_development_dependency 'base64' # stdlib gem on Ruby 3.4+
33
+ spec.add_development_dependency 'bigdecimal' # required by crack (webmock) on Ruby 3.4+
34
+ spec.add_development_dependency 'ostruct' # stdlib gem on Ruby 3.5+ (json/simplecov load chain)
35
+ spec.add_development_dependency 'logger' # stdlib gem on Ruby 3.4+ (mime-types / rest-client load chain)
32
36
  spec.add_development_dependency 'bundler', '~> 2.4.6'
33
37
  spec.add_development_dependency 'pry', '~> 0.14.1'
34
38
  spec.add_development_dependency 'rake', '~> 13.0'
@@ -39,7 +43,7 @@ Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength
39
43
  spec.add_development_dependency 'rubocop-rake', '~> 0.5.1'
40
44
  spec.add_development_dependency 'rubocop-rspec', '~> 2.3'
41
45
  spec.add_development_dependency 'simplecov', '~> 0.21.2'
42
- spec.add_development_dependency 'yard', '~> 0.9.26'
46
+ spec.add_development_dependency 'yard', '~> 0.9.42'
43
47
  spec.add_development_dependency 'webmock', '~> 3.18'
44
- spec.add_development_dependency 'vcr', '~> 6.1.0'
48
+ spec.add_development_dependency 'vcr', '~> 6.4'
45
49
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zerobounce-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 2.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zero Bounce
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-02-06 00:00:00.000000000 Z
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rest-client
@@ -28,16 +28,72 @@ dependencies:
28
28
  name: dotenv
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: 2.8.1
33
+ version: '0'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: base64
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: bigdecimal
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: ostruct
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: logger
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
39
95
  - !ruby/object:Gem::Version
40
- version: 2.8.1
96
+ version: '0'
41
97
  - !ruby/object:Gem::Dependency
42
98
  name: bundler
43
99
  requirement: !ruby/object:Gem::Requirement
@@ -190,14 +246,14 @@ dependencies:
190
246
  requirements:
191
247
  - - "~>"
192
248
  - !ruby/object:Gem::Version
193
- version: 0.9.26
249
+ version: 0.9.42
194
250
  type: :development
195
251
  prerelease: false
196
252
  version_requirements: !ruby/object:Gem::Requirement
197
253
  requirements:
198
254
  - - "~>"
199
255
  - !ruby/object:Gem::Version
200
- version: 0.9.26
256
+ version: 0.9.42
201
257
  - !ruby/object:Gem::Dependency
202
258
  name: webmock
203
259
  requirement: !ruby/object:Gem::Requirement
@@ -218,14 +274,14 @@ dependencies:
218
274
  requirements:
219
275
  - - "~>"
220
276
  - !ruby/object:Gem::Version
221
- version: 6.1.0
277
+ version: '6.4'
222
278
  type: :development
223
279
  prerelease: false
224
280
  version_requirements: !ruby/object:Gem::Requirement
225
281
  requirements:
226
282
  - - "~>"
227
283
  - !ruby/object:Gem::Version
228
- version: 6.1.0
284
+ version: '6.4'
229
285
  description: A Ruby client for Zerobounce.net.
230
286
  email:
231
287
  - integrations@zerobounce.net
@@ -233,36 +289,50 @@ executables: []
233
289
  extensions: []
234
290
  extra_rdoc_files: []
235
291
  files:
236
- - ".env.sample"
292
+ - ".dockerignore"
293
+ - ".env.example"
294
+ - ".github/auto_assign.yml"
295
+ - ".github/dependabot.yml"
296
+ - ".github/workflows/auto_assign_ci.yaml"
297
+ - ".github/workflows/codeql.yml"
298
+ - ".github/workflows/publish.yml"
299
+ - ".github/workflows/sdk_ci.yml"
237
300
  - ".gitignore"
238
301
  - ".rspec"
239
302
  - ".ruby-version"
240
303
  - CHANGELOG.md
241
304
  - CODE_OF_CONDUCT.md
242
305
  - CONTRIBUTING.md
306
+ - Dockerfile
243
307
  - Gemfile
244
308
  - Gemfile.lock
245
309
  - LICENSE
246
310
  - LICENSE.txt
247
311
  - README.md
248
312
  - Rakefile
313
+ - SECURITY.md
249
314
  - bin/bundle
250
315
  - bin/console
251
316
  - bin/setup
252
317
  - data/zerobounce-ai-scoring.csv
253
318
  - data/zerobounce-batch-validation.csv
254
319
  - documentation.md
255
- - documentation_es.md
256
320
  - files/scoring.csv
257
321
  - files/validation.csv
258
322
  - files/zerobounce-ai-scoring.csv
259
323
  - files/zerobounce-batch-validation.csv
260
324
  - lib/zerobounce.rb
325
+ - lib/zerobounce/api_urls.rb
261
326
  - lib/zerobounce/base_request.rb
262
327
  - lib/zerobounce/configuration.rb
328
+ - lib/zerobounce/download_type.rb
263
329
  - lib/zerobounce/error.rb
330
+ - lib/zerobounce/get_file_helper.rb
331
+ - lib/zerobounce/get_file_options.rb
264
332
  - lib/zerobounce/mock_request.rb
265
333
  - lib/zerobounce/request.rb
334
+ - lib/zerobounce/validate_status.rb
335
+ - lib/zerobounce/validate_sub_status.rb
266
336
  - lib/zerobounce/version.rb
267
337
  - zerobounce.gemspec
268
338
  homepage: https://zerobounce.net
@@ -270,7 +340,7 @@ licenses:
270
340
  - MIT
271
341
  metadata:
272
342
  yard.run: yri
273
- post_install_message:
343
+ post_install_message:
274
344
  rdoc_options: []
275
345
  require_paths:
276
346
  - lib
@@ -285,8 +355,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
285
355
  - !ruby/object:Gem::Version
286
356
  version: '0'
287
357
  requirements: []
288
- rubygems_version: 3.4.6
289
- signing_key:
358
+ rubygems_version: 3.4.19
359
+ signing_key:
290
360
  specification_version: 4
291
361
  summary: A Ruby client for Zerobounce.net.
292
362
  test_files: []
data/.env.sample DELETED
@@ -1,3 +0,0 @@
1
- ZEROBOUNCE_API_KEY=99e7ef20ceea4480a173b07b1be75371
2
- INCORRECT_API_KEY=thiskeyisinvalidorotherwiseincorrect
3
- TEST=unit