jbr 3.12.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -0
  3. data/CHANGELOG.md +59 -0
  4. data/README.md +155 -165
  5. data/lib/graphql/client.rb +2 -6
  6. data/lib/graphql/throttled.rb +0 -9
  7. data/lib/jbr/account.rb +44 -15
  8. data/lib/jbr/authorizing.rb +43 -0
  9. data/lib/jbr/collection.rb +14 -0
  10. data/lib/jbr/collections/customers.rb +54 -0
  11. data/lib/jbr/collections/invoices.rb +18 -0
  12. data/lib/jbr/{jobs.rb → collections/jobs.rb} +6 -6
  13. data/lib/jbr/collections/leads.rb +30 -0
  14. data/lib/jbr/collections/locations.rb +47 -0
  15. data/lib/jbr/collections/quotes.rb +18 -0
  16. data/lib/jbr/{visits.rb → collections/visits.rb} +8 -8
  17. data/lib/jbr/error.rb +1 -1
  18. data/lib/jbr/errors/throttled.rb +6 -0
  19. data/lib/jbr/includable.rb +6 -8
  20. data/lib/jbr/listable.rb +12 -34
  21. data/lib/jbr/mock/account.rb +25 -7
  22. data/lib/jbr/mock/invoice.rb +7 -11
  23. data/lib/jbr/mock/invoices.rb +7 -0
  24. data/lib/jbr/mock/jobs.rb +10 -6
  25. data/lib/jbr/mock/leads.rb +7 -0
  26. data/lib/jbr/mock/quote.rb +3 -8
  27. data/lib/jbr/mock/quotes.rb +7 -0
  28. data/lib/jbr/mock/visit.rb +3 -19
  29. data/lib/jbr/mock/visits.rb +9 -5
  30. data/lib/jbr/mock.rb +16 -3
  31. data/lib/jbr/phone.rb +5 -8
  32. data/lib/jbr/querying.rb +57 -0
  33. data/lib/jbr/reader.rb +9 -0
  34. data/lib/jbr/refreshing.rb +0 -8
  35. data/lib/jbr/resources/customer.rb +23 -0
  36. data/lib/jbr/resources/invoice.rb +29 -0
  37. data/lib/jbr/resources/job.rb +20 -0
  38. data/lib/jbr/resources/lead.rb +7 -0
  39. data/lib/jbr/resources/line.rb +15 -0
  40. data/lib/jbr/resources/location.rb +31 -0
  41. data/lib/jbr/resources/quote.rb +15 -0
  42. data/lib/jbr/resources/visit.rb +17 -0
  43. data/lib/jbr/version.rb +1 -1
  44. data/lib/jbr.rb +37 -46
  45. metadata +39 -28
  46. data/lib/jbr/client.rb +0 -98
  47. data/lib/jbr/cliental.rb +0 -14
  48. data/lib/jbr/invoice.rb +0 -43
  49. data/lib/jbr/itemized.rb +0 -9
  50. data/lib/jbr/job.rb +0 -40
  51. data/lib/jbr/line_item.rb +0 -36
  52. data/lib/jbr/mock/client.rb +0 -17
  53. data/lib/jbr/mock/job.rb +0 -31
  54. data/lib/jbr/mock/line_item.rb +0 -10
  55. data/lib/jbr/mock/oauth.rb +0 -22
  56. data/lib/jbr/mock/property.rb +0 -19
  57. data/lib/jbr/mock/request.rb +0 -12
  58. data/lib/jbr/mock/url.rb +0 -9
  59. data/lib/jbr/mocking.rb +0 -30
  60. data/lib/jbr/named.rb +0 -9
  61. data/lib/jbr/oauth.rb +0 -94
  62. data/lib/jbr/properted.rb +0 -17
  63. data/lib/jbr/property.rb +0 -93
  64. data/lib/jbr/quote.rb +0 -25
  65. data/lib/jbr/request.rb +0 -37
  66. data/lib/jbr/resource.rb +0 -26
  67. data/lib/jbr/retriable.rb +0 -20
  68. data/lib/jbr/url.rb +0 -16
  69. data/lib/jbr/visit.rb +0 -24
  70. /data/lib/jbr/{refused.rb → errors/refused.rb} +0 -0
@@ -3,14 +3,5 @@ module GraphQL
3
3
  # itself. Worth telling apart: the same query is answered once the budget it is priced
4
4
  # against has refilled.
5
5
  class Throttled < Error
6
- # @param message [String] what the endpoint said, with the numbers it said it with.
7
- # @param cost [Hash] what it priced the query at, and the budget it priced it against.
8
- def initialize(message, cost = {})
9
- super message
10
- @cost = cost
11
- end
12
-
13
- # @return [Hash] those numbers, in the endpoint's own words.
14
- attr_reader :cost
15
6
  end
16
7
  end
data/lib/jbr/account.rb CHANGED
@@ -1,23 +1,52 @@
1
1
  module Jbr
2
- # The Jobber account a set of credentials belongs to.
3
- class Account < Resource
4
- # The query that reads the account behind the current credentials.
5
- FIND = <<~GRAPHQL
6
- { account { id name phone } }
7
- GRAPHQL
2
+ # Credentials for one Jobber account, and the gateway to all they read or write.
3
+ class Account < Company::Account
4
+ include Querying, Refreshing
5
+ extend Authorizing
8
6
 
9
- # @return [String, nil] the account ID.
10
- def id = node['id']
7
+ # The query that reads the business behind the credentials, by the keys the vocabulary reads.
8
+ BUSINESS = "{ account { #{Company::Business.node_keys.join ' '} } }"
11
9
 
12
- # @return [String, nil] what the business calls itself.
13
- def name = node['name']
10
+ # Answers from {Jbr.mock} instead of Jobber once an app under test has touched it.
11
+ # @param credentials [Hash] see {#initialize}.
12
+ # @return [Account] credentials, mocked where the app asked for that.
13
+ def self.new(credentials = {})
14
+ Jbr.mocked? && self == Account ? Mock::Account.new(credentials) : super
15
+ end
14
16
 
15
- # @return [String, nil] the number the business is reached on, as Jobber holds it.
16
- def phone = node['phone']
17
+ # @param credentials [Hash] the tokens, their expiry, the account, when it went bad, and
18
+ # the `store:` these are kept in, where processes share them. See {Refreshing}.
19
+ def initialize(credentials = {})
20
+ @access_token = credentials[:access_token]
21
+ @refresh_token = credentials[:refresh_token]
22
+ @expires_at = credentials[:expires_at]
23
+ @invalid_at = credentials[:invalid_at]
24
+ @account_id = credentials[:account_id]
25
+ @store = credentials[:store]
26
+ end
17
27
 
18
- private
28
+ # The credentials as Jobber last gave them, plus the moment a refusal to refresh landed.
29
+ attr_reader :access_token, :refresh_token, :expires_at, :invalid_at
19
30
 
20
- # Read once and remembered, whichever of the three is asked for first.
21
- def node = @node = @node.presence || @oauth.query(FIND).fetch('account', {})
31
+ # @return [String, nil] ID of the account these credentials reach.
32
+ attr_accessor :account_id
33
+
34
+ # @return [Company::Business] business the credentials belong to, read from Jobber.
35
+ def business = Company::Business.new node: query(BUSINESS).fetch('account', {})
36
+
37
+ # @return [Jobs] jobs of the business.
38
+ def jobs = Jobs.new account: self
39
+
40
+ # @return [Visits] visits of the business.
41
+ def visits = Visits.new account: self
42
+
43
+ # @return [Quotes] quotes of the business, which Jobber alone lists.
44
+ def quotes = Quotes.new account: self
45
+
46
+ # @return [Leads] leads of the business.
47
+ def leads = Leads.new account: self
48
+
49
+ # @return [Invoices] invoices of the business, which Jobber alone answers for.
50
+ def invoices = Invoices.new account: self
22
51
  end
23
52
  end
@@ -0,0 +1,43 @@
1
+ module Jbr
2
+ # What the account class does before there are credentials: sends a Jobber user to authorize
3
+ # the app, and trades the code they come back with for a first set.
4
+ module Authorizing
5
+ # Where a Jobber user authorizes the app.
6
+ AUTHORIZE = 'https://api.getjobber.com/api/oauth/authorize'
7
+
8
+ # @param code [String] code Jobber sent to the redirect URI.
9
+ # @param redirect_uri [String] URI the code came back to.
10
+ # @return [Account] credentials for the account that authorized the app, its ID learned.
11
+ # @raise [Error] where Jobber will not take the code.
12
+ def create(code:, redirect_uri:)
13
+ credentials = post code: code, redirect_uri: redirect_uri, grant_type: 'authorization_code'
14
+ new(credentials).tap { |account| account.account_id = account.business.id }
15
+ end
16
+
17
+ # @param redirect_uri [String] where Jobber sends the user back to.
18
+ # @param state [String] what the app recognizes them by when they come back.
19
+ # @return [String] URL a Jobber user authorizes the app on.
20
+ def url_for(redirect_uri:, state:)
21
+ return Jbr.mock.oauth_url if Jbr.mocked?
22
+
23
+ uri = URI AUTHORIZE
24
+ uri.query = URI.encode_www_form redirect_uri: redirect_uri, state: state,
25
+ response_type: 'code', client_id: client_id
26
+ uri.to_s
27
+ end
28
+
29
+ # @return [String, nil] client ID the app is registered with Jobber as.
30
+ def client_id = ENV['JOBBER_CLIENT_ID']
31
+
32
+ # @return [String, nil] client secret the app proves itself to Jobber with.
33
+ def client_secret = ENV['JOBBER_CLIENT_SECRET']
34
+
35
+ # @param params [Hash] grant to exchange: a code, or a refresh token.
36
+ # @return [Hash] tokens Jobber answered, and the moment the access one expires.
37
+ def post(params = {})
38
+ return Mock::Account.post params if Jbr.mocked?
39
+
40
+ Token.post params.merge(client_id: client_id, client_secret: client_secret)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,14 @@
1
+ module Jbr
2
+ # A list of records read through the credentials, with what it was asked to bring back beside
3
+ # each record and what it was narrowed to.
4
+ class Collection < Company::Collection
5
+ # @param account [Account] credentials to reach Jobber with.
6
+ # @param includes [Hash] what to bring back beside each record, by name.
7
+ # @param filter [Hash, nil] what the list was narrowed to, in the shape Jobber filters by.
8
+ def initialize(account:, includes: {}, filter: nil)
9
+ @account = account
10
+ @includes = includes
11
+ @filter = filter
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,54 @@
1
+ module Jbr
2
+ # The clients on a Jobber account, reached by phone and opened where none answers to it.
3
+ class Customers < Reader
4
+ # The query that finds a client by phone, with the properties already on file.
5
+ LOOKUP = <<~GRAPHQL
6
+ query($searchTerm: String!) {
7
+ clientPhones(first: 1, searchTerm: $searchTerm) { nodes {
8
+ client { id updatedAt clientProperties { nodes { id address { #{Location::ADDRESS} } } } }
9
+ } }
10
+ }
11
+ GRAPHQL
12
+
13
+ # The mutation that opens a client, with a first property where an address is given.
14
+ CREATE = <<~GRAPHQL
15
+ mutation($input: ClientCreateInput!) {
16
+ clientCreate(input: $input) {
17
+ client { id clientProperties(first: 1) { nodes { id address { #{Location::ADDRESS} } } } }
18
+ userErrors { message }
19
+ }
20
+ }
21
+ GRAPHQL
22
+
23
+ # Reach the client answering to a number, opening one with the rest where Jobber has none.
24
+ # @param phone [String] number to match on, and to file a new client under.
25
+ # @param name [String] what to call them.
26
+ # @param surname [String, nil] their surname.
27
+ # @param email [String, nil] address they are written to.
28
+ # @param address [Hash] any of :street, :city, :state and :zip, the first place on file.
29
+ # @return [Customer] the client, with the places on their file.
30
+ def find_or_create_by(phone:, name:, surname:, email:, address:)
31
+ find_by(phone) || create(phone:, name:, surname:, email:, address:)
32
+ end
33
+
34
+ private
35
+
36
+ def find_by(phone)
37
+ output = @account.query LOOKUP, variables: { searchTerm: phone }
38
+ recent = output.dig('clientPhones', 'nodes').to_a.max_by do |node|
39
+ node.dig('client', 'updatedAt') || ''
40
+ end
41
+ Customer.new node: recent['client'] if recent
42
+ end
43
+
44
+ def create(phone:, name:, surname:, email:, address:)
45
+ input = { firstName: name, lastName: surname,
46
+ phones: [ { number: phone, primary: true } ],
47
+ emails: ([ { address: email, primary: true } ] if email.present?),
48
+ properties: ([ { address: Locations.address_from(address) } ] if address.present?),
49
+ }.compact
50
+ output = @account.query CREATE, variables: { input: input }
51
+ Customer.new node: output.dig('clientCreate', 'client')
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,18 @@
1
+ module Jbr
2
+ # The invoices on a Jobber account, each reached by the ID Jobber files it under.
3
+ class Invoices < Reader
4
+ # The query that reads one invoice, its total, its date and the job it bills.
5
+ FIND = <<~GRAPHQL
6
+ query($id: EncodedId!) {
7
+ invoice(id: $id) { id total invoiceStatus issuedDate jobs { nodes { id completedAt } } }
8
+ }
9
+ GRAPHQL
10
+
11
+ # @param id [String] Jobber ID of the invoice.
12
+ # @return [Invoice, nil] nil when Jobber has no invoice under that ID, or holds it as a draft.
13
+ def find(id)
14
+ node = @account.query(FIND, variables: { id: id })['invoice']
15
+ Invoice.new node: node if node && node['invoiceStatus'] != 'draft'
16
+ end
17
+ end
18
+ end
@@ -1,19 +1,19 @@
1
1
  module Jbr
2
2
  # The jobs on a Jobber account, oldest first, walked a page at a time.
3
- class Jobs < Resource
3
+ class Jobs < Collection
4
4
  include Includable, Listable
5
5
 
6
- # What a job answers with wherever one is read, before anything it was asked to bring
7
- # back with it.
8
- FIELDS = 'id title instructions jobStatus total createdAt startAt completedAt ' \
6
+ # What a job answers with wherever one is read, before anything it was asked to bring back
7
+ # with it. Written by hand where the vocabulary's keys would not do: the quote nests.
8
+ FIELDS = 'id title instructions total createdAt startAt completedAt ' \
9
9
  'quote { id amounts { total } }'
10
10
 
11
11
  # Shadows Enumerable#find on purpose, the way Active Record does: a job is reached by the
12
12
  # ID Jobber files it under, not by asking every job on the account whether it is the one.
13
- # @param id [String] the Jobber ID of the job.
13
+ # @param id [String] Jobber ID of the job.
14
14
  # @return [Job, nil] nil when Jobber has no job under that ID.
15
15
  def find(id)
16
- node = @oauth.query(one, variables: { id: id })['job']
16
+ node = @account.query(one, variables: { id: id })['job']
17
17
  Job.new node: node if node
18
18
  end
19
19
 
@@ -0,0 +1,30 @@
1
+ module Jbr
2
+ # The requests on a Jobber account's board: work somebody asked the business for.
3
+ class Leads < Company::Leads
4
+ # The mutation that opens a request against a client and a property.
5
+ CREATE = <<~GRAPHQL
6
+ mutation($input: RequestCreateInput!) {
7
+ requestCreate(input: $input) { request { id client { id } } userErrors { message } }
8
+ }
9
+ GRAPHQL
10
+
11
+ # @param account [Account] credentials to reach Jobber with.
12
+ def initialize(account:)
13
+ @account = account
14
+ end
15
+
16
+ # Files a request against the client answering to the phone and the property at the address,
17
+ # opening either where Jobber has none. The description is the request's title and the
18
+ # notes its instructions; Jobber has no source for a request, so that one is dropped.
19
+ # @return [Lead] the request, and the client it was opened against.
20
+ def create(name:, surname:, phone:, email:, address:, description:, notes:, source:)
21
+ customer = Customers.new(account: @account).find_or_create_by phone: phone,
22
+ name: name, surname: surname, email: email, address: address
23
+ location_id = Locations.new(account: @account).find_or_create_for customer, address
24
+ input = { clientId: customer.id, propertyId: location_id, title: description,
25
+ assessment: { instructions: notes }, }
26
+ output = @account.query CREATE, variables: { input: input }
27
+ Lead.new node: output.dig('requestCreate', 'request')
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,47 @@
1
+ module Jbr
2
+ # The properties on a client's file, and the one a request is opened at.
3
+ class Locations < Reader
4
+ # The mutation that adds a property to a client already on file.
5
+ CREATE = <<~GRAPHQL
6
+ mutation($clientId: EncodedId!, $input: PropertyCreateInput!) {
7
+ propertyCreate(clientId: $clientId, input: $input) {
8
+ properties { id }
9
+ userErrors { message }
10
+ }
11
+ }
12
+ GRAPHQL
13
+
14
+ # What Jobber calls each address field, against what a caller passes.
15
+ FIELDS = { street1: :street, city: :city, province: :state, postalCode: :zip }
16
+
17
+ # The address as Jobber takes it, without the fields a caller left blank.
18
+ # @param fields [Hash] any of :street, :city, :state and :zip.
19
+ # @return [Hash] address, by Jobber's names.
20
+ def self.address_from(fields)
21
+ FIELDS.to_h { |jobber, ours| [ jobber, fields[ours] ] }.compact_blank
22
+ end
23
+
24
+ # Reach the property at an address on a client's file, adding one where none matches.
25
+ # @param customer [Customer] whose file, with the places already on it.
26
+ # @param address [Hash] any of :street, :city, :state and :zip.
27
+ # @return [String, nil] ID of the property, or nil where Jobber declined to add one.
28
+ def find_or_create_for(customer, address)
29
+ wanted = self.class.address_from address
30
+ match = customer.locations.find { |location| same_address? wanted, location }
31
+ match ? match.id : create(customer.id, wanted)
32
+ end
33
+
34
+ private
35
+
36
+ def create(client_id, address)
37
+ output = @account.query CREATE, variables: {
38
+ clientId: client_id, input: { properties: [ { address: address } ] },
39
+ }
40
+ output.dig 'propertyCreate', 'properties', 0, 'id'
41
+ end
42
+
43
+ def same_address?(wanted, location)
44
+ wanted[:street1] == location.street && wanted[:postalCode] == location.zip
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,18 @@
1
+ module Jbr
2
+ # The quotes on a Jobber account, each reached by the ID Jobber files it under.
3
+ class Quotes < Reader
4
+ # The query that reads one quote, what it comes to, and the request it came from.
5
+ FIND = <<~GRAPHQL
6
+ query($id: EncodedId!) {
7
+ quote(id: $id) { id amounts { total } request { id } }
8
+ }
9
+ GRAPHQL
10
+
11
+ # @param id [String] Jobber ID of the quote.
12
+ # @return [Quote, nil] nil when Jobber has no quote under that ID.
13
+ def find(id)
14
+ node = @account.query(FIND, variables: { id: id })['quote']
15
+ Quote.new node: node if node
16
+ end
17
+ end
18
+ end
@@ -1,28 +1,28 @@
1
1
  module Jbr
2
2
  # The visits on a Jobber account, oldest first, walked a page at a time.
3
- class Visits < Resource
4
- include Includable, Listable
3
+ class Visits < Collection
4
+ include Listable
5
5
 
6
- # What a visit answers with, before anything it was asked to bring back with it.
7
- FIELDS = 'id title startAt endAt allDay clientConfirmed job { id }'
6
+ # What a visit answers with: the keys the vocabulary reads, and the job it belongs to.
7
+ FIELDS = "#{Visit.node_keys.join ' '} job { id }"
8
8
 
9
9
  # Shadows Enumerable#find on purpose, the way jobs do: a visit is reached by the ID Jobber
10
10
  # files it under, not by asking every visit on the account whether it is the one.
11
- # @param id [String] the Jobber ID of the visit.
11
+ # @param id [String] Jobber ID of the visit.
12
12
  # @return [Visit, nil] nil when Jobber has no visit under that ID.
13
13
  def find(id)
14
- node = @oauth.query(one, variables: { id: id })['visit']
14
+ node = @account.query(one, variables: { id: id })['visit']
15
15
  Visit.new node: node if node
16
16
  end
17
17
 
18
18
  private
19
19
 
20
- def page = paged "#{FIELDS} #{selections}", PAGE
20
+ def page = paged FIELDS, PAGE
21
21
 
22
22
  def one
23
23
  <<~GRAPHQL
24
24
  query($id: EncodedId!) {
25
- visit(id: $id) { #{FIELDS} #{selections} }
25
+ visit(id: $id) { #{FIELDS} }
26
26
  }
27
27
  GRAPHQL
28
28
  end
data/lib/jbr/error.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Jbr
2
2
  # An error raised when Jobber refuses a request.
3
- Error = Class.new StandardError
3
+ Error = Class.new Company::Error
4
4
  end
@@ -0,0 +1,6 @@
1
+ module Jbr
2
+ # Jobber refusing a query over what it costs rather than over anything about the query. The
3
+ # bucket it is priced against refills, so the same question asked later is answered; the
4
+ # numbers it was refused with stay in the message.
5
+ Throttled = Class.new Company::Throttled
6
+ end
@@ -2,26 +2,24 @@ module Jbr
2
2
  # Extends a list of records with the chaining that says what to bring back beside them.
3
3
  # Nothing extra comes back unasked: a page costs what it carries.
4
4
  module Includable
5
- # @param names [Array<Symbol, Hash>] :client, :line_items, :property, or
6
- # property: :client for the client whose file the place sits on.
7
- # @return [Resource] the same list, asking Jobber for those too.
5
+ # @param names [Array<Symbol, Hash>] :lines, :location, or location: :customer for whose
6
+ # place it is.
7
+ # @return [Collection] the same list, asking Jobber for those too.
8
8
  def includes(*names)
9
9
  named = names.each_with_object({}) do |name, all|
10
10
  name.is_a?(Hash) ? all.merge!(name) : all[name] = nil
11
11
  end
12
- self.class.new oauth: @oauth, includes: @includes.merge(named)
12
+ self.class.new account: @account, includes: @includes.merge(named), filter: @filter
13
13
  end
14
14
 
15
15
  private
16
16
 
17
- # What the includes ask Jobber for, in its own words.
18
17
  def selections = @includes.map { |name, nested| selection_of name, nested }.join ' '
19
18
 
20
19
  def selection_of(name, nested)
21
20
  case name
22
- when :client then Cliental::SELECTION
23
- when :line_items then LineItem::SELECTION
24
- when :property then Properted.selection client: nested == :client
21
+ when :lines then Line::SELECTION
22
+ when :location then Location.selection customer: nested == :customer
25
23
  end
26
24
  end
27
25
  end
data/lib/jbr/listable.rb CHANGED
@@ -3,11 +3,9 @@ module Jbr
3
3
  # a query by the page it asks for and by what every row of that page carries, so a list is
4
4
  # read either as records or as the IDs alone, each with a page sized to what it carries.
5
5
  module Listable
6
- include Enumerable
7
-
8
6
  # Records a page, not forty and not a hundred: what an includes brings back is charged for
9
- # on top of every row of it, so a page of jobs carrying their lines, their property and its
10
- # client priced past what a bucket holds. Half the page costs half the query and loses
7
+ # on top of every row of it, so a page of jobs carrying their lines, their location and its
8
+ # customer priced past what a bucket holds. Half the page costs half the query and loses
11
9
  # nothing, since a walk simply reads more pages.
12
10
  PAGE = 20
13
11
 
@@ -19,42 +17,24 @@ module Jbr
19
17
  # starts, and a page is read only once the one before it runs out.
20
18
  def each(&) = walk(page).each(&)
21
19
 
22
- # @param within [ActiveSupport::Duration, Numeric, nil] how far ahead to look, or nil for
23
- # as far ahead as the account is scheduled.
24
- # @return [Listable] the same list, narrowed to what is scheduled from now on.
25
- def upcoming(within = nil)
26
- now = Time.now
27
- narrowed after: now, before: (now + within if within)
28
- end
29
-
30
- # @param within [ActiveSupport::Duration, Numeric, nil] how far back to look, or nil for
31
- # as far back as the account goes.
32
- # @return [Listable] the same list, narrowed to what started before now.
33
- def past(within = nil)
34
- now = Time.now
35
- narrowed before: now, after: (now - within if within)
20
+ # The two halves of a schedule split at one moment rather than per page: read page by page
21
+ # the boundary would slide, and something could cross it unseen.
22
+ # @param from [Time, nil] the moment the window opens, or nothing for as far back as it goes.
23
+ # @param to [Time, nil] the moment the window closes, or nothing for as far ahead as it goes.
24
+ # @return [Collection] the same list, narrowed to what starts between the two.
25
+ def between(from, to)
26
+ bounds = { after: from&.iso8601, before: to&.iso8601 }.compact
27
+ self.class.new account: @account, includes: @includes, filter: { startAt: bounds }
36
28
  end
37
29
 
38
30
  # The ID Jobber files each record under, and nothing else about it: the cheapest question
39
31
  # an account can be walked with, and the one to ask where every record is then read on its
40
- # own through {#find}.
32
+ # own through `find`.
41
33
  # @return [Array<String>] every ID in the list, every page of them read.
42
34
  def ids = walk(ids_page).map(&:id)
43
35
 
44
36
  private
45
37
 
46
- # The two halves of a schedule, split at the moment they are asked for rather than per
47
- # page: read page by page the boundary would slide, and something could cross it unseen.
48
- # The same moment is the near end of a window, so the far end is measured from it too.
49
- def narrowed(after: nil, before: nil)
50
- bounds = { after: after&.iso8601, before: before&.iso8601 }.compact
51
- self.class.new oauth: @oauth, includes: @includes, filter: { startAt: bounds }
52
- end
53
-
54
- # Whether a moment falls in the stretch of the schedule the list was narrowed to, for
55
- # anything answering one without asking Jobber. A record with no moment at all is in an
56
- # open-ended upcoming list, since nothing has started it, and in no window, since a window
57
- # is a stretch it would have to have fallen in.
58
38
  def scheduled?(at)
59
39
  return true unless @filter
60
40
 
@@ -64,13 +44,11 @@ module Jbr
64
44
  (after.nil? || at >= Time.iso8601(after)) && (before.nil? || at <= Time.iso8601(before))
65
45
  end
66
46
 
67
- # Every record a paged query answers, one at a time, a page read only once the one before
68
- # it runs out. The filter is data: narrowed to nothing, the query narrows nothing.
69
47
  def walk(statement)
70
48
  Enumerator.new do |yielder|
71
49
  after = nil
72
50
  loop do
73
- answered = @oauth.query statement, variables: { after: after, filter: @filter }.compact
51
+ answered = @account.query statement, variables: { after: after, filter: @filter }.compact
74
52
  current = answered.fetch field, {}
75
53
  current.fetch('nodes', []).each { |node| yielder << item(node) }
76
54
  break unless current.dig 'pageInfo', 'hasNextPage'
@@ -1,15 +1,33 @@
1
1
  module Jbr
2
- # The account that reads from {Jbr.mock} instead of Jobber.
2
+ # Credentials that answer from {Jbr.mock} instead of Jobber.
3
3
  class Mock::Account < Account
4
- # @return [Object, nil] the values the app asked for, and 'account-01' for an ID it did not.
5
- def id = node.fetch :id, 'account-01'
4
+ # @return [Company::Business] business the app named, under `account-01` where it named
5
+ # no ID.
6
+ def business = Company::Business.new node: { id: 'account-01' }.merge(Jbr.mock.business.to_h)
6
7
 
7
- def name = node[:name]
8
+ # @return [Mock::Jobs] jobs the app listed.
9
+ def jobs = Mock::Jobs.new account: self
8
10
 
9
- def phone = node[:phone]
11
+ # @return [Mock::Visits] visits the app listed.
12
+ def visits = Mock::Visits.new account: self
10
13
 
11
- private
14
+ # @return [Mock::Quotes] the one quote the app named.
15
+ def quotes = Mock::Quotes.new
12
16
 
13
- def node = Jbr.mock.account.to_h
17
+ # @return [Mock::Leads] the one lead the app named.
18
+ def leads = Mock::Leads.new
19
+
20
+ # @return [Mock::Invoices] the one invoice the app named.
21
+ def invoices = Mock::Invoices.new
22
+
23
+ # Revoking a mocked token asks nobody.
24
+ def delete; end
25
+
26
+ # @return [Hash] canned credentials, unless the app asked for a refusal.
27
+ def self.post(_)
28
+ raise Error, Jbr.mock.oauth_error if Jbr.mock.oauth_error
29
+
30
+ { access_token: 'mock-token', refresh_token: 'mock-token', expires_at: Time.now + 3600 }
31
+ end
14
32
  end
15
33
  end
@@ -1,18 +1,14 @@
1
1
  module Jbr
2
- # An invoice that reads from {Jbr.mock} instead of Jobber.
2
+ # An invoice an app under test named, keyed by the readers' names rather than Jobber's.
3
3
  class Mock::Invoice < Invoice
4
- # @return [Mock::Invoice] itself, carrying the mocked IDs and total.
5
- def find(_)
6
- @id = Jbr.mock.invoice[:id]
7
- @job_id = Jbr.mock.invoice[:job_id]
8
- @total = Jbr.mock.invoice[:total]
4
+ # The mock spells every key as the reader is named.
5
+ def self.keys = {}
9
6
 
10
- self
11
- end
7
+ # @return [Company::Job, nil] job the app named beside the invoice.
8
+ def job = record Company::Job, :job
12
9
 
13
- # @return [Time, nil] the dates the app asked for.
14
- def issued_at = Jbr.mock.invoice[:issued_at]
10
+ private
15
11
 
16
- def completed_at = Jbr.mock.invoice[:completed_at]
12
+ def completed_at = time :completed_at
17
13
  end
18
14
  end
@@ -0,0 +1,7 @@
1
+ module Jbr
2
+ # The one invoice an app under test asked {Jbr.mock} to answer with.
3
+ class Mock::Invoices
4
+ # @return [Mock::Invoice, nil] invoice the app named, whatever ID is asked for.
5
+ def find(_id) = (Mock::Invoice.new node: Jbr.mock.invoice if Jbr.mock.invoice)
6
+ end
7
+ end
data/lib/jbr/mock/jobs.rb CHANGED
@@ -3,20 +3,24 @@ module Jbr
3
3
  # narrowing a list, and reading it as records or as IDs, is the same code a real one runs.
4
4
  class Mock::Jobs < Jobs
5
5
  # The job filed under that ID where the app listed one, and otherwise the single job it
6
- # named which is every app that mocks a lookup without mocking a list.
7
- # @return [Mock::Job] the job asked for.
8
- def find(id) = Mock::Job.new node: listed(id) || Jbr.mock.job
6
+ # named, which is every app that mocks a lookup without mocking a list.
7
+ # @param id [String] ID the app filed the job under.
8
+ # @return [Company::Job, nil] job asked for, nil where the app named none.
9
+ def find(id)
10
+ node = listed(id) || Jbr.mock.job
11
+ Company::Job.new node: node if node
12
+ end
9
13
 
10
14
  private
11
15
 
12
16
  def walk(_statement)
13
- Enumerator.new { |yielder| selected.each { |job| yielder << Mock::Job.new(node: job) } }
17
+ Enumerator.new do |yielder|
18
+ selected.each { |node| yielder << Company::Job.new(node: node) }
19
+ end
14
20
  end
15
21
 
16
22
  def selected = Jbr.mock.jobs.select { |job| scheduled? job[:scheduled_at] }
17
23
 
18
- # Only a real list is looked through: an app that mocked the list as something raising
19
- # was mocking the walk failing, and a lookup is a question of its own.
20
24
  def listed(id) = (Jbr.mock.jobs.find { |job| job[:id] == id } if Jbr.mock.jobs.is_a? Array)
21
25
  end
22
26
  end
@@ -0,0 +1,7 @@
1
+ module Jbr
2
+ # The one lead an app under test asked {Jbr.mock} to answer with.
3
+ class Mock::Leads
4
+ # @return [Company::Lead] lead the app named, whatever was asked to be filed.
5
+ def create(**) = Company::Lead.new node: Jbr.mock.lead.to_h
6
+ end
7
+ end
@@ -1,12 +1,7 @@
1
1
  module Jbr
2
- # A quote that reads from {Jbr.mock} instead of Jobber.
2
+ # A quote an app under test named, its lead under the mock's own key rather than Jobber's.
3
3
  class Mock::Quote < Quote
4
- # @return [Mock::Quote] itself, carrying the mocked IDs.
5
- def find(_)
6
- @id = Jbr.mock.quote[:id]
7
- @request_id = Jbr.mock.quote[:request_id]
8
-
9
- self
10
- end
4
+ # @return [Company::Lead, nil] lead the app named beside the quote.
5
+ def lead = record Company::Lead, :lead
11
6
  end
12
7
  end