kiosk-test-support 0.5.20

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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +149 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +146 -0
  5. data/lib/kiosk/story_spec.rb +6 -0
  6. data/lib/kiosk/story_test.rb +16 -0
  7. data/lib/kiosk/test_helpers/assistant/events.rb +135 -0
  8. data/lib/kiosk/test_helpers/assistant.rb +151 -0
  9. data/lib/kiosk/test_helpers/conformance/checks/declared_shape.rb +91 -0
  10. data/lib/kiosk/test_helpers/conformance/checks/executes.rb +57 -0
  11. data/lib/kiosk/test_helpers/conformance/checks/helpers.rb +112 -0
  12. data/lib/kiosk/test_helpers/conformance/checks/principal_scope.rb +104 -0
  13. data/lib/kiosk/test_helpers/conformance/checks/routes.rb +94 -0
  14. data/lib/kiosk/test_helpers/conformance/checks.rb +45 -0
  15. data/lib/kiosk/test_helpers/conformance/in_process_origin.rb +121 -0
  16. data/lib/kiosk/test_helpers/conformance/minitest.rb +57 -0
  17. data/lib/kiosk/test_helpers/conformance/null_origin.rb +55 -0
  18. data/lib/kiosk/test_helpers/conformance/outcome.rb +25 -0
  19. data/lib/kiosk/test_helpers/conformance/rspec.rb +100 -0
  20. data/lib/kiosk/test_helpers/conformance/verb.rb +36 -0
  21. data/lib/kiosk/test_helpers/conformance.rb +46 -0
  22. data/lib/kiosk/test_helpers/customer.rb +154 -0
  23. data/lib/kiosk/test_helpers/descriptor_examples.rb +46 -0
  24. data/lib/kiosk/test_helpers/errors.rb +55 -0
  25. data/lib/kiosk/test_helpers/in_process_executor.rb +198 -0
  26. data/lib/kiosk/test_helpers/in_process_wire.rb +90 -0
  27. data/lib/kiosk/test_helpers/journey.rb +98 -0
  28. data/lib/kiosk/test_helpers/kyc.rb +98 -0
  29. data/lib/kiosk/test_helpers/live_server.rb +56 -0
  30. data/lib/kiosk/test_helpers/null_executor.rb +95 -0
  31. data/lib/kiosk/test_helpers/person.rb +52 -0
  32. data/lib/kiosk/test_helpers/small_equihash.rb +18 -0
  33. data/lib/kiosk/test_helpers/story.rb +36 -0
  34. data/lib/kiosk/test_helpers/stripe_mock.rb +146 -0
  35. data/lib/kiosk/test_helpers/version.rb +7 -0
  36. data/lib/kiosk/test_helpers/wire.rb +124 -0
  37. data/lib/kiosk/test_helpers.rb +28 -0
  38. metadata +205 -0
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Needs kiosk-server, which only a Rails host carries; this gem does not depend
4
+ # on it, so a test helper requires this file explicitly:
5
+ # require "kiosk/test_helpers/conformance/in_process_origin"
6
+ # require "kiosk/test_helpers/conformance/rspec" # or .../minitest
7
+ # Kiosk::TestHelpers::Conformance.origin = Kiosk::TestHelpers::Conformance::InProcessOrigin.new
8
+ require 'securerandom'
9
+
10
+ require 'kiosk/server'
11
+ require 'kiosk/test_helpers/conformance'
12
+ require 'kiosk/test_helpers/in_process_wire'
13
+
14
+ module Kiosk
15
+ module TestHelpers
16
+ module Conformance
17
+ # The origin the conformance checks run against: the booted app's own
18
+ # registries, routes and validators. A call goes through the verb's route
19
+ # as an assistant's does, with a token the origin signs for the principal.
20
+ # Calls do not roll back; undoing writes is the test framework's job.
21
+ class InProcessOrigin
22
+ # @param app [#call] the Rack app a call goes through; defaults to `Rails.application`
23
+ # @param routes [ActionDispatch::Routing::RouteSet] defaults to `Rails.application.routes`
24
+ def initialize(app: nil, routes: nil)
25
+ @wire = InProcessWire.new(app: app)
26
+ @routes = routes
27
+ end
28
+
29
+ def mount_path = Kiosk.configuration.mount_path
30
+
31
+ # Not memoised: development rebuilds the registry at `to_prepare`.
32
+ def verbs
33
+ descriptors(Kiosk::Server::Queries, :query) + descriptors(Kiosk::Server::Actions, :action)
34
+ end
35
+
36
+ # The action a request reaches, as `{controller:, action:, kiosk_verb:}`; nil when no route answers.
37
+ def recognize(path, method:)
38
+ verb_route(path, method.to_s.upcase) ||
39
+ route_set.recognize_path(path, method: method.to_s.downcase.to_sym).slice(:controller, :action, :kiosk_verb)
40
+ rescue ActionController::RoutingError
41
+ nil
42
+ end
43
+
44
+ def call(name, kind:, params:, as: nil)
45
+ @wire.call(name, kind: kind, params: params, identity: identity_for(as))
46
+ end
47
+
48
+ # Uses the wire's own validators, so a query's reserved `limit`/`cursor` pass as they do on the wire.
49
+ def schema_errors(payload, schema:, verb:, kind:, slot:)
50
+ if slot.to_s == 'input_schema'
51
+ Kiosk::Server::RequestValidation.validate_arguments!(payload, input_schema: schema, verb: verb,
52
+ query: kind == :query)
53
+ else
54
+ Kiosk::Server::ResponseValidation.validate_payload!(payload, output_schema: schema, verb: verb,
55
+ kind: kind)
56
+ end
57
+ []
58
+ rescue Kiosk::Server::Errors::Base => e
59
+ ["#{slot}: #{e.message}"]
60
+ end
61
+
62
+ # A record contributes its `#id`; anything else is the principal id itself.
63
+ def identity_for(subject, role: nil)
64
+ raise ArgumentError, NO_PRINCIPAL if subject.nil?
65
+
66
+ Kiosk::Identity.new(
67
+ user_id: subject.respond_to?(:id) ? subject.id : subject,
68
+ role: resolve_role(subject, role), actor: 'agent', agent_id: SecureRandom.uuid
69
+ )
70
+ end
71
+
72
+ NO_PRINCIPAL =
73
+ 'a conformance call needs a principal — pass `as:` a record or a principal id. ' \
74
+ 'Every verb is served behind authentication, so there is no anonymous call ' \
75
+ 'to make.'
76
+ private_constant :NO_PRINCIPAL
77
+
78
+ private
79
+
80
+ def descriptors(registry, kind)
81
+ registry.catalog.map do |descriptor|
82
+ Verb.new(
83
+ name: descriptor[:name], kind: kind, reach: descriptor[:reach],
84
+ input_schema: descriptor[:input_schema], output_schema: descriptor[:output_schema],
85
+ example_params: descriptor[:example_params], endpoint: endpoint(registry.fetch(descriptor[:name]))
86
+ )
87
+ end
88
+ end
89
+
90
+ # The action that answers the verb: its own, or the engine's verb endpoint.
91
+ def endpoint(handler)
92
+ controller = handler.controller if handler.is_a?(Kiosk::Server::ControllerVerb)
93
+ "#{controller.controller_path}##{handler.method_name}" if controller
94
+ end
95
+
96
+ # The verb route `kiosk do` drew at `path`, as the action it reaches.
97
+ def verb_route(path, method)
98
+ route = route_set.routes.find { |r| verb_route?(r) && r.verb == method && r.path.match(path) }
99
+ return unless route
100
+
101
+ controller, action = route.app.app.endpoint.split('#')
102
+ { controller: controller, action: action, kiosk_verb: route.app.app.name }
103
+ end
104
+
105
+ def verb_route?(route)
106
+ route.app.respond_to?(:app) && route.app.app.is_a?(Kiosk::Server::Routing::VerbRoute)
107
+ end
108
+
109
+ def route_set = @routes || ::Rails.application.routes
110
+
111
+ def resolve_role(subject, explicit)
112
+ return explicit.to_s if explicit
113
+ return subject.role.to_s if subject.respond_to?(:role) && subject.role
114
+
115
+ roles = Kiosk.configuration.roles
116
+ roles.first.to_s if roles && !roles.empty?
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/assertions'
4
+
5
+ require 'kiosk/test_helpers/conformance'
6
+
7
+ module Kiosk
8
+ module TestHelpers
9
+ module Conformance
10
+ # Minitest assertions for the four conformance checks; required by name only.
11
+ # Each returns its {Outcome}.
12
+ module Assertions
13
+ def assert_kiosk_verbs_routed(message = nil, origin: nil)
14
+ kiosk_conformance!(Conformance.routes(kiosk_conformance_origin(origin)), message)
15
+ end
16
+
17
+ def assert_kiosk_verb_executes(name, params: Checks::EXAMPLE, as: nil,
18
+ message: nil, origin: nil)
19
+ kiosk_conformance!(
20
+ Checks.executes(kiosk_conformance_origin(origin), name, params: params, as: as),
21
+ message
22
+ )
23
+ end
24
+
25
+ def assert_kiosk_answer_matches_declared_schema(name, params: Checks::EXAMPLE, as: nil,
26
+ message: nil, origin: nil)
27
+ kiosk_conformance!(
28
+ Checks.declared_shape(kiosk_conformance_origin(origin), name, params: params, as: as),
29
+ message
30
+ )
31
+ end
32
+
33
+ # rubocop:disable-next Metrics/ParameterLists -- public assertion signature; every keyword is part of it
34
+ def assert_kiosk_scoped_to_principal(name, as:, and_not:, params: Checks::EXAMPLE,
35
+ message: nil, origin: nil)
36
+ kiosk_conformance!(
37
+ Checks.principal_scope(kiosk_conformance_origin(origin), name,
38
+ as: as, and_not: and_not, params: params),
39
+ message
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ # `assert` rather than `flunk`, so a passing check is counted.
46
+ def kiosk_conformance!(outcome, message)
47
+ assert(outcome.ok?, message || outcome.message)
48
+ outcome
49
+ end
50
+
51
+ def kiosk_conformance_origin(explicit)
52
+ explicit || Conformance.require_origin!
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kiosk/test_helpers/conformance/verb'
4
+
5
+ module Kiosk
6
+ module TestHelpers
7
+ module Conformance
8
+ # A hand-built origin with no Rails, Postgres or engine; this gem's own
9
+ # suite runs the checks against it. The origin contract:
10
+ #
11
+ # #mount_path → String, e.g. "/kiosk"
12
+ # #verbs → Array<Verb>
13
+ # #recognize(path, method:) → {controller:, action:, kiosk_verb:} or nil
14
+ # #call(name, kind:, params:, as:) → the verb's answer, or raises
15
+ # #schema_errors(payload, schema:, verb:, kind:, slot:) → Array<String> (optional)
16
+ class NullOrigin
17
+ attr_reader :mount_path, :verbs, :calls
18
+
19
+ # @param routes [Hash] `[method, path] => {controller:, action:, kiosk_verb:}`
20
+ # @param answers [Hash] `[verb_name, principal]` or `verb_name` => answer; an Exception is raised
21
+ def initialize(mount_path: '/kiosk', verbs: [], routes: nil, answers: {})
22
+ @mount_path = mount_path
23
+ @verbs = verbs
24
+ @routes = routes || self.class.routes_for(mount_path, verbs)
25
+ @answers = answers
26
+ @calls = []
27
+ end
28
+
29
+ # The route table a correctly routed origin would have.
30
+ def self.routes_for(mount_path, verbs)
31
+ verbs.each_with_object({}) do |verb, table|
32
+ path = "#{mount_path.chomp('/')}/#{verb.name}"
33
+ controller, action = verb.endpoint.split('#')
34
+ table[[verb.http_method, path]] = {
35
+ controller: controller, action: action, kiosk_verb: verb.name
36
+ }
37
+ end
38
+ end
39
+
40
+ def recognize(path, method:)
41
+ @routes[[method.to_s.upcase, path]]
42
+ end
43
+
44
+ def call(name, kind:, params:, as: nil)
45
+ @calls << { name: name, kind: kind, params: params, as: as }
46
+ answer = @answers.fetch([name, as]) { @answers.fetch(name, nil) }
47
+ answer = answer.call(params: params, as: as) if answer.is_a?(Proc)
48
+ raise answer if answer.is_a?(::Exception)
49
+
50
+ answer
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module TestHelpers
5
+ module Conformance
6
+ # The result of one check. A failure is returned, never raised; `message` is
7
+ # the sentence both adapters render, `details` the facts behind it.
8
+ Outcome = Data.define(:ok, :check, :subject, :message, :details) do
9
+ def ok? = ok
10
+
11
+ def failed? = !ok
12
+ end
13
+
14
+ def self.pass(check, **) = outcome(true, check, **)
15
+
16
+ def self.fail(check, **) = outcome(false, check, **)
17
+
18
+ def self.outcome(passed, check, subject: nil, message: '', details: {})
19
+ Outcome.new(ok: passed, check: check.to_sym, subject: subject,
20
+ message: message.to_s, details: details || {})
21
+ end
22
+ private_class_method :outcome
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/expectations'
4
+
5
+ require 'kiosk/test_helpers/conformance'
6
+
7
+ module Kiosk
8
+ module TestHelpers
9
+ module Conformance
10
+ # RSpec matchers for the four conformance checks; required by name only.
11
+ module RSpecHelpers
12
+ def kiosk_origin = Conformance.require_origin!
13
+ end
14
+
15
+ # The matcher DSL hands keywords to a block as one positional Hash.
16
+ module RSpecOptions
17
+ module_function
18
+
19
+ def origin(options)
20
+ options[:origin] || Conformance.require_origin!
21
+ end
22
+
23
+ def params(options)
24
+ options.fetch(:params, Checks::EXAMPLE)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ # `expect(kiosk_origin).to have_a_route_for_every_verb`
32
+ # Without `|options = {}|` a keyword `origin:` would be silently dropped.
33
+ RSpec::Matchers.define :have_a_route_for_every_verb do |options = {}|
34
+ match do |origin|
35
+ @outcome = Kiosk::TestHelpers::Conformance::Checks.routes(
36
+ origin || Kiosk::TestHelpers::Conformance::RSpecOptions.origin(options)
37
+ )
38
+ @outcome.ok?
39
+ end
40
+
41
+ failure_message { @outcome.message }
42
+ failure_message_when_negated { "expected some verb to be unrouted, but #{@outcome.message}" }
43
+ description { 'have a route for every declared verb' }
44
+ end
45
+
46
+ # `expect(:catalog).to execute_as_a_kiosk_verb`
47
+ RSpec::Matchers.define :execute_as_a_kiosk_verb do |options = {}|
48
+ match do |name|
49
+ @outcome = Kiosk::TestHelpers::Conformance::Checks.executes(
50
+ Kiosk::TestHelpers::Conformance::RSpecOptions.origin(options), name,
51
+ params: Kiosk::TestHelpers::Conformance::RSpecOptions.params(options),
52
+ as: options[:as]
53
+ )
54
+ @outcome.ok?
55
+ end
56
+
57
+ failure_message { @outcome.message }
58
+ failure_message_when_negated { "expected #{@outcome.subject} to refuse, but #{@outcome.message}" }
59
+ description { 'execute as a Kiosk verb' }
60
+ end
61
+
62
+ # `expect(:catalog).to answer_its_declared_schema`
63
+ RSpec::Matchers.define :answer_its_declared_schema do |options = {}|
64
+ match do |name|
65
+ @outcome = Kiosk::TestHelpers::Conformance::Checks.declared_shape(
66
+ Kiosk::TestHelpers::Conformance::RSpecOptions.origin(options), name,
67
+ params: Kiosk::TestHelpers::Conformance::RSpecOptions.params(options),
68
+ as: options[:as]
69
+ )
70
+ @outcome.ok?
71
+ end
72
+
73
+ failure_message { @outcome.message }
74
+ failure_message_when_negated do
75
+ "expected #{@outcome.subject} to answer something its own schema rejects, " \
76
+ "but #{@outcome.message}"
77
+ end
78
+ description { 'answer the shape its own output_schema declares' }
79
+ end
80
+
81
+ # `expect(:my_orders).to be_scoped_to_principal(as: alice, and_not: bob)`
82
+ RSpec::Matchers.define :be_scoped_to_principal do |options = {}|
83
+ match do |name|
84
+ @outcome = Kiosk::TestHelpers::Conformance::Checks.principal_scope(
85
+ Kiosk::TestHelpers::Conformance::RSpecOptions.origin(options), name,
86
+ as: options.fetch(:as),
87
+ and_not: options.fetch(:and_not),
88
+ params: Kiosk::TestHelpers::Conformance::RSpecOptions.params(options)
89
+ )
90
+ @outcome.ok?
91
+ end
92
+
93
+ failure_message { @outcome.message }
94
+ failure_message_when_negated do
95
+ "expected #{@outcome.subject} to leak across principals, but #{@outcome.message}"
96
+ end
97
+ description { 'be scoped to the calling principal' }
98
+ end
99
+
100
+ RSpec.configure { |config| config.include(Kiosk::TestHelpers::Conformance::RSpecHelpers) } if defined?(RSpec) && RSpec.respond_to?(:configure)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module TestHelpers
5
+ module Conformance
6
+ HTTP_METHODS = { query: 'GET', action: 'POST' }.freeze
7
+
8
+ # The engine's own endpoints, which answer the verbs the engine serves.
9
+ WIRE_ENDPOINTS = {
10
+ query: 'kiosk/server/verb#show',
11
+ action: 'kiosk/server/verb#create'
12
+ }.freeze
13
+
14
+ # One declared verb, as an origin's descriptor presents it to the checks.
15
+ # `endpoint` is the `controller#action` that answers it.
16
+ Verb = Data.define(:name, :kind, :reach, :input_schema, :output_schema, :example_params, :endpoint) do
17
+ def initialize(name:, kind:, reach: 'principal', endpoint: nil, **schemas)
18
+ kind = kind.to_sym
19
+ raise ArgumentError, "kind must be :query or :action, got #{kind.inspect}" unless HTTP_METHODS.key?(kind)
20
+
21
+ super(name: name.to_s, kind: kind, reach: (reach || 'principal').to_s,
22
+ endpoint: endpoint || WIRE_ENDPOINTS.fetch(kind),
23
+ input_schema: nil, output_schema: nil, example_params: nil, **schemas)
24
+ end
25
+
26
+ def query? = kind == :query
27
+
28
+ def http_method = HTTP_METHODS.fetch(kind)
29
+
30
+ def other_http_method = HTTP_METHODS.fetch(query? ? :action : :query)
31
+
32
+ def to_s = "#{kind} #{name.inspect}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kiosk/test_helpers/errors'
4
+ require 'kiosk/test_helpers/conformance/outcome'
5
+ require 'kiosk/test_helpers/conformance/verb'
6
+ require 'kiosk/test_helpers/conformance/checks'
7
+ require 'kiosk/test_helpers/conformance/null_origin'
8
+
9
+ module Kiosk
10
+ module TestHelpers
11
+ # Four checks an origin runs against itself: routes resolve, a verb executes,
12
+ # an answer matches its declared schema, data access is scoped to the principal.
13
+ # Set `Conformance.origin`, then require the minitest or rspec adapter.
14
+ module Conformance
15
+ class << self
16
+ # Anything answering the {NullOrigin} contract.
17
+ attr_accessor :origin
18
+
19
+ def require_origin!
20
+ origin || raise(Errors::OriginNotConfigured)
21
+ end
22
+
23
+ def reset!
24
+ @origin = nil
25
+ end
26
+
27
+ # The four {Checks} against the configured origin, returning an {Outcome}.
28
+ def routes(origin = require_origin!)
29
+ Checks.routes(origin)
30
+ end
31
+
32
+ def executes(name, params: Checks::EXAMPLE, as: nil, origin: require_origin!)
33
+ Checks.executes(origin, name, params: params, as: as)
34
+ end
35
+
36
+ def declared_shape(name, params: Checks::EXAMPLE, as: nil, origin: require_origin!)
37
+ Checks.declared_shape(origin, name, params: params, as: as)
38
+ end
39
+
40
+ def principal_scope(name, as:, and_not:, params: Checks::EXAMPLE, origin: require_origin!)
41
+ Checks.principal_scope(origin, name, as: as, and_not: and_not, params: params)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'jwt'
5
+ require 'kiosk/test_helpers/assistant'
6
+
7
+ module Kiosk
8
+ module TestHelpers
9
+ # What the origin answered: what was asked for, or a refusal with its code
10
+ # and a hint about what to do instead.
11
+ class Answer
12
+ def initialize(response) = @response = response
13
+
14
+ def ok? = @response.status.between?(200, 299)
15
+
16
+ # A problem document's `code`, or an OAuth `error` such as `authorization_pending`.
17
+ def refused?(code) = !ok? && [@response.body['code'], @response.body['error']].include?(code.to_s)
18
+
19
+ def hint = @response.body['hint'].to_s
20
+ def detail = @response.body['detail'].to_s
21
+ def [](key) = @response.body[key.to_s]
22
+ def rows = @response.body
23
+ def header(name) = @response[name]
24
+
25
+ # How many proofs of work the assistant solved to be answered.
26
+ def tolls_paid = @response.proofs
27
+
28
+ # The proofs of work that pay the toll this refusal demands.
29
+ def solved_toll = Wire.solve(self['challenges'])
30
+
31
+ # The `Link` target with `rel="next"`: the rest of a truncated list.
32
+ def next_page = header('link').to_s[/<([^>]*)>\s*;\s*rel="next"/, 1]
33
+
34
+ def to_s = "#{@response.status} #{@response.body}"
35
+ alias inspect to_s
36
+ end
37
+
38
+ # Someone's AI assistant at this origin. A demo subclasses it to name what
39
+ # its customers do there: a shopper orders and pays, a rider reserves and rides.
40
+ class Customer
41
+ attr_reader :principal
42
+
43
+ # An assistant holding a key and no account at its origin yet.
44
+ def self.newcomer(assistant)
45
+ new(assistant, Assistant::Principal.new(agent_id: nil, user_id: nil, token: nil, rsa_key: OpenSSL::PKey::RSA.generate(2048)))
46
+ end
47
+
48
+ def initialize(assistant, principal)
49
+ @assistant = assistant
50
+ @principal = principal
51
+ end
52
+
53
+ def origin = @assistant.base_url
54
+
55
+ # Pays any proof-of-work toll asked for, unless `unpaid:` or carrying its own `proofs:`.
56
+ def asks(query, headers: {}, unpaid: false, proofs: nil, **params)
57
+ headers = headers.merge('Kiosk-PoW' => JSON.generate(proofs)) if proofs
58
+ Answer.new(@assistant.query(principal, name: query.to_s, headers:, pay_tolls: !(unpaid || proofs), **params))
59
+ end
60
+
61
+ def does(action, headers: {}, **args) = Answer.new(@assistant.run(principal, name: action.to_s, headers:, **args))
62
+
63
+ # Signs intent, cart and payment mandates for `total` cents and pays.
64
+ def pays(total:, scope:, line_items:, currency: 'eur')
65
+ Answer.new(@assistant.pay(principal, **@assistant.mandates(principal, total:, scope:, line_items:, currency:)))
66
+ end
67
+
68
+ # Signs the `cart` an action answered with, unchanged, and pays.
69
+ def pays_cart(cart, scope:, currency: cart['currency'])
70
+ pays(total: cart['total_amount_cents'], scope:, line_items: cart['line_items'], currency:)
71
+ end
72
+
73
+ # The answer says whether a card is on file, or carries the page where the person saves one.
74
+ def sets_up_payment = does(:payment_setup)
75
+
76
+ # Listens for the check's outcome, then asks for it; the answer carries the page
77
+ # the assistant hands to its person.
78
+ def requests_verification
79
+ listens_for(:kyc_verification)
80
+ does(:request_kyc).tap { @verification = it['request_id'] if it.ok? }
81
+ end
82
+
83
+ # Whether the origin reports the verification this customer asked for last passed.
84
+ # rubocop:disable-next Naming/PredicateMethod -- public journey DSL name the demo stories call
85
+ def hears_verification_passed = hears(:kyc_verification, about: @verification)['status'] == 'approved'
86
+
87
+ # Hands the origin an identity attestation the person obtained elsewhere.
88
+ def presents(attestation) = Answer.new(@assistant.kyc(principal, attestation_jws: attestation))
89
+
90
+ # What the origin signed into the credential: whose account, in which role.
91
+ def claims = JWT.decode(principal.token, nil, false).first
92
+ def account = claims['sub']
93
+ def role = claims['role']
94
+
95
+ def signs_back_in = Answer.new(@assistant.login(principal.rsa_key))
96
+
97
+ # This assistant holding the credential a fresh sign-in issues.
98
+ def with_a_fresh_credential = holding(signs_back_in)
99
+
100
+ # Redeems the link code a person handed over; the assistant then acts for that person.
101
+ def redeems(link_code) = holding(Answer.new(@assistant.claim(link_code, principal.rsa_key)))
102
+
103
+ # Asks to be linked to a person's account; the answer carries the code the person approves.
104
+ def asks_to_be_linked(client_id: 'kiosk-story')
105
+ Answer.new(@assistant.device_authorization(client_id:, public_key: principal.rsa_key.public_key.to_pem))
106
+ end
107
+
108
+ # Asks once whether the person has approved `request`.
109
+ def polls(request) = Answer.new(@assistant.device_token(request['device_code'], principal.rsa_key))
110
+
111
+ # This assistant holding the credential the person granted through `request`.
112
+ def collects(request) = holding(polls(request))
113
+
114
+ # News the origin publishes on `topic` from now on reaches the assistant.
115
+ def listens_for(topic)
116
+ @news ||= @assistant.events(principal)
117
+ @news.subscribe(topic.to_s)
118
+ end
119
+
120
+ # A connection of its own to the news on `topics`, about `subject`, from after event `since`.
121
+ def follows(*topics, subject: nil, since: nil)
122
+ @assistant.events(principal).tap do |news|
123
+ topics.each { news.subscribe(it.to_s, **{ subject:, since: }.compact) }
124
+ (@followed ||= []) << news
125
+ end
126
+ end
127
+
128
+ # What the origin told the assistant on `topic` (about `subject`, carrying `data`),
129
+ # in the shape the origin publishes for that topic.
130
+ def hears(topic, about: nil, on: @news, **data)
131
+ event = on.await { heard?(it, topic, about, data) }
132
+ errors = Assistant::Events.payload_errors(@assistant.schema, [event])
133
+ raise Assistant::Events::Error, "#{origin} published #{event} off its schema: #{errors.join('; ')}" if errors.any?
134
+
135
+ event['data']
136
+ end
137
+
138
+ private
139
+
140
+ def heard?(event, topic, about, data)
141
+ event['topic'] == topic.to_s && (about.nil? || event['subject'] == about.to_s) &&
142
+ data.all? { |key, value| event.dig('data', key.to_s) == value }
143
+ end
144
+
145
+ def holding(answer)
146
+ raise "#{origin} issued no credential: #{answer}" unless answer.ok?
147
+
148
+ token = answer['access_token']
149
+ held = JWT.decode(token, nil, false).first
150
+ self.class.new(@assistant, principal.with(agent_id: held['agent_id'], user_id: held['sub'], token:))
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+
5
+ module Kiosk
6
+ module TestHelpers
7
+ # The examples a served /kiosk/schema publishes, each beside the schema it
8
+ # must satisfy: `example_params` against `input_schema`, `example_row`
9
+ # against one row of `output_schema`.
10
+ module DescriptorExamples
11
+ PAGING = %w[limit cursor].freeze
12
+
13
+ Example = Data.define(:verb, :slot, :value, :schema) do
14
+ def violation
15
+ return "#{verb}: #{slot} has no schema to satisfy" unless schema
16
+
17
+ errors = JSONSchemer.schema(schema, meta_schema: 'https://json-schema.org/draft/2020-12/schema')
18
+ .validate(value).first(3).map { it['error'] }
19
+ "#{verb}: #{slot} violates its schema — #{errors.join('; ')}" if errors.any?
20
+ end
21
+ end
22
+
23
+ def self.of(document)
24
+ (Array(document['queries']) + Array(document['actions'])).flat_map do |descriptor|
25
+ [params(descriptor), row(descriptor)].compact
26
+ end
27
+ end
28
+
29
+ def self.params(descriptor)
30
+ return unless descriptor.key?('example_params')
31
+
32
+ declared = descriptor['input_schema']
33
+ undeclared = PAGING - (declared&.dig('properties')&.keys || [])
34
+ Example.new(descriptor['name'], 'example_params', descriptor['example_params'].except(*undeclared), declared)
35
+ end
36
+
37
+ def self.row(descriptor)
38
+ return unless descriptor.key?('example_row')
39
+
40
+ declared = descriptor['output_schema']
41
+ declared = declared.except('type', 'description', 'items').merge(declared['items']) if declared&.dig('type') == 'array' && declared['items'].is_a?(Hash)
42
+ Example.new(descriptor['name'], 'example_row', descriptor['example_row'], declared)
43
+ end
44
+ end
45
+ end
46
+ end