holdfast 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.ja.md +235 -0
- data/README.md +191 -0
- data/bin/holdfast +4 -0
- data/lib/holdfast/callers.rb +124 -0
- data/lib/holdfast/change.rb +59 -0
- data/lib/holdfast/check.rb +473 -0
- data/lib/holdfast/cli.rb +105 -0
- data/lib/holdfast/draft.rb +303 -0
- data/lib/holdfast/drawn.rb +186 -0
- data/lib/holdfast/harness.rb +90 -0
- data/lib/holdfast/inputs.rb +105 -0
- data/lib/holdfast/record.rb +132 -0
- data/lib/holdfast/runtime/check_support.rb +666 -0
- data/lib/holdfast/runtime/observe.rb +136 -0
- data/lib/holdfast/runtime/reach.rb +104 -0
- data/lib/holdfast/sequences/propose.rb +1506 -0
- data/lib/holdfast/sequences/run.rb +197 -0
- data/lib/holdfast/sequences/sequences.rb +1325 -0
- data/lib/holdfast/stages/diff.rb +213 -0
- data/lib/holdfast/stages/fill.rb +194 -0
- data/lib/holdfast/stages/fold.rb +256 -0
- data/lib/holdfast/stages/hunt.rb +176 -0
- data/lib/holdfast/stages/observe.rb +109 -0
- data/lib/holdfast/stages/select.rb +94 -0
- data/lib/holdfast/tools.rb +33 -0
- data/lib/holdfast/verdict.rb +69 -0
- data/lib/holdfast/version.rb +5 -0
- data/lib/holdfast.rb +44 -0
- data/skills/holdfast/SKILL.md +96 -0
- metadata +122 -0
|
@@ -0,0 +1,1506 @@
|
|
|
1
|
+
# Drafts sequence definitions for a Rails app: an action
|
|
2
|
+
# per write route, Grape endpoint and job, a pool per model they take, a world
|
|
3
|
+
# built from the factories, the fakes the specs install, and each fake's
|
|
4
|
+
# failure modes. A short random walk (the probe) records which tables each
|
|
5
|
+
# action writes and which fakes it calls; areas are grouped by it.
|
|
6
|
+
#
|
|
7
|
+
# The hunt stage runs it from the app root with the app's bundle:
|
|
8
|
+
# ruby propose.rb --all [--force] one file per write area in SEQUENCES_OUT, plus invariants.rb
|
|
9
|
+
# ruby propose.rb AREA [AREA...] one draft on stdout; an AREA is a regexp over paths and job names
|
|
10
|
+
ENV["RAILS_ENV"] = "test" # the probe needs the test database and the factories
|
|
11
|
+
$LOAD_PATH.unshift(File.expand_path("spec", Dir.pwd))
|
|
12
|
+
require "rspec/core"
|
|
13
|
+
require "rails_helper"
|
|
14
|
+
require_relative "sequences"
|
|
15
|
+
require "prism"
|
|
16
|
+
|
|
17
|
+
module Propose
|
|
18
|
+
MAX_ACTIONS = 10
|
|
19
|
+
FAILURE_NAME = /fail|time_?out|declin|refus|reject|unavailable|error/i
|
|
20
|
+
|
|
21
|
+
Draft = Data.define(:name, :params, :body, :kind, :resource, :file)
|
|
22
|
+
Failure = Data.define(:name, :code, :service)
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# The model a param or path word names: `loan_id` is a Loan,
|
|
27
|
+
# `pickup_branch_id` a Branch (by a belongs_to of that name), `request_id`
|
|
28
|
+
# an InterlibraryRequest (the one model whose name ends in Request).
|
|
29
|
+
def model_for(word)
|
|
30
|
+
stem = word.to_s.sub(/_id\z/, "")
|
|
31
|
+
found = stem.classify.safe_constantize
|
|
32
|
+
return found if found.is_a?(Class) && found < ActiveRecord::Base
|
|
33
|
+
|
|
34
|
+
by_assoc = models.flat_map { it.reflect_on_all_associations(:belongs_to) }.find { it.name.to_s == stem && !it.polymorphic? }
|
|
35
|
+
return by_assoc.klass if by_assoc
|
|
36
|
+
|
|
37
|
+
suffix = models.select { it.name.underscore.end_with?("_#{stem}") }
|
|
38
|
+
suffix.first if suffix.one?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pool_name(model) = model.model_name.plural.to_sym
|
|
42
|
+
|
|
43
|
+
# A string param named like an identifying column (a code, a barcode, an
|
|
44
|
+
# ISBN) reads it from a record the world has: `barcode` from a Copy.
|
|
45
|
+
# A status or a reason is free text instead: copying one from an unrelated
|
|
46
|
+
# table draws from an empty pool.
|
|
47
|
+
IDENTIFIER = /code|barcode|isbn|token|email|ref\z|reference\z/
|
|
48
|
+
def column_source(key, models)
|
|
49
|
+
# A param named like a record (`branch`, `pickup_branch`) sends its identifying column.
|
|
50
|
+
if (model = model_for(key)) && (column = model.columns.find { it.type == :string && it.name.match?(IDENTIFIER) })
|
|
51
|
+
return [model, column.name]
|
|
52
|
+
end
|
|
53
|
+
# `carnet_code` sends a carnet's code.
|
|
54
|
+
if (m = key.to_s.match(/\A(\w+?)_(\w+)\z/)) && m[2].match?(IDENTIFIER) && (model = model_for(m[1])) && model.columns_hash[m[2]]&.type == :string
|
|
55
|
+
return [model, m[2]]
|
|
56
|
+
end
|
|
57
|
+
return unless key.to_s.match?(IDENTIFIER)
|
|
58
|
+
|
|
59
|
+
seeded = models.sort_by { FactoryBot.factories.registered?(it.model_name.singular) ? 0 : 1 }
|
|
60
|
+
seeded.each { |m| return [m, key.to_s] if m.column_names.include?(key.to_s) && m.columns_hash[key.to_s].type == :string }
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Guessed draws for an untyped numeric param, marked in the draft.
|
|
65
|
+
PERCENTS = [0, 50, 100, 101, -1].freeze
|
|
66
|
+
COUNTS = { /minutes|duration/ => [0, 15, 30, 60, 120, 240, 600], /size|count|attendees|seats|quantity|number|days/ => [0, 1, 2, 4, 12, -1],
|
|
67
|
+
/amount|price|total|fee|cost/ => ["0", "1", "12.50", "100", "-5", "0.01"] }.freeze
|
|
68
|
+
|
|
69
|
+
def models
|
|
70
|
+
@models ||= ActiveRecord::Base.descendants.reject { it.abstract_class? || it.name.nil? }.select(&:table_exists?)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# What a controller action reads from params: key => [model, attribute],
|
|
74
|
+
# [:date], [:nested, keys] for `require(:k).permit(...)`, [:upload], or [:value].
|
|
75
|
+
def controller_params(controller, action)
|
|
76
|
+
file = controller.instance_method(action).source_location&.first or return {}
|
|
77
|
+
found = {}
|
|
78
|
+
nest = lambda do |key, attrs|
|
|
79
|
+
found[key] = [:nested, ((found[key]&.first == :nested ? found[key].last : []) + attrs).uniq]
|
|
80
|
+
end
|
|
81
|
+
walk = lambda do |node, stack|
|
|
82
|
+
# Rails 8's `params.expect(membership: [:plan_id])` and
|
|
83
|
+
# `params.dig(:membership, :starts_on)` read a nested form too.
|
|
84
|
+
if node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :params && node.name == :expect
|
|
85
|
+
Array(node.arguments&.arguments).grep(Prism::KeywordHashNode).flat_map(&:elements).each do |pair|
|
|
86
|
+
next unless pair.key.is_a?(Prism::SymbolNode) && pair.value.is_a?(Prism::ArrayNode)
|
|
87
|
+
|
|
88
|
+
nest.(pair.key.unescaped.to_sym, pair.value.elements.grep(Prism::SymbolNode).map { it.unescaped.to_sym })
|
|
89
|
+
end
|
|
90
|
+
elsif node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :params && node.name == :dig &&
|
|
91
|
+
(keys = Array(node.arguments&.arguments)).size == 2 && keys.all?(Prism::SymbolNode)
|
|
92
|
+
nest.(keys.first.unescaped.to_sym, [keys.last.unescaped.to_sym])
|
|
93
|
+
elsif node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :params &&
|
|
94
|
+
%i[require [] fetch].include?(node.name) && node.arguments&.arguments&.first.is_a?(Prism::SymbolNode)
|
|
95
|
+
key = node.arguments.arguments.first.unescaped.to_sym
|
|
96
|
+
at = stack.rindex { it.is_a?(Prism::CallNode) && %i[find find_by find_by!].include?(it.name) && it.receiver.is_a?(Prism::ConstantReadNode) }
|
|
97
|
+
pair = at && stack[at..].reverse.find { it.is_a?(Prism::AssocNode) }
|
|
98
|
+
model = at && stack[at].receiver.name.to_s.safe_constantize
|
|
99
|
+
dated = stack.last.is_a?(Prism::CallNode) && %i[to_date to_time].include?(stack.last.name)
|
|
100
|
+
outer = stack.last if stack.last.is_a?(Prism::CallNode) && stack.last.receiver.equal?(node)
|
|
101
|
+
# `params.require(:tickets).map { it.permit(:kind, ...) }` is a list of forms.
|
|
102
|
+
each_permit = if %i[map each filter_map flat_map].include?(outer&.name) && outer.block
|
|
103
|
+
found_permit = nil
|
|
104
|
+
todo = [outer.block]
|
|
105
|
+
while (n = todo.pop)
|
|
106
|
+
found_permit = n if n.is_a?(Prism::CallNode) && n.name == :permit && n.receiver
|
|
107
|
+
todo.concat(n.compact_child_nodes)
|
|
108
|
+
end
|
|
109
|
+
found_permit
|
|
110
|
+
end
|
|
111
|
+
found[key] ||= if outer&.name == :permit
|
|
112
|
+
[:nested, outer.arguments.arguments.grep(Prism::SymbolNode).map { it.unescaped.to_sym }]
|
|
113
|
+
elsif each_permit
|
|
114
|
+
[:nested_list, Array(each_permit.arguments&.arguments).grep(Prism::SymbolNode).map { it.unescaped.to_sym }]
|
|
115
|
+
elsif %i[read original_filename path].include?(outer&.name) then [:upload]
|
|
116
|
+
elsif model then [model, pair ? pair.key.unescaped.to_sym : :id]
|
|
117
|
+
elsif dated then [:date]
|
|
118
|
+
elsif model_for(key) then [model_for(key), :id]
|
|
119
|
+
else [:value]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
node.compact_child_nodes.each { walk.(it, stack + [node]) }
|
|
123
|
+
end
|
|
124
|
+
tree = Prism.parse_file(file).value
|
|
125
|
+
defn = tree.breadth_first_search { it.is_a?(Prism::DefNode) && it.name == action.to_sym }
|
|
126
|
+
defn&.then { walk.(it, []) }
|
|
127
|
+
# The usual `member_params` helper holds the form's keys, so a method of
|
|
128
|
+
# the same file the action calls with no receiver is read too.
|
|
129
|
+
if defn
|
|
130
|
+
called = []
|
|
131
|
+
stack = [defn]
|
|
132
|
+
while (n = stack.pop)
|
|
133
|
+
called << n.name if n.is_a?(Prism::CallNode) && n.receiver.nil? && n.arguments.nil?
|
|
134
|
+
stack.concat(n.compact_child_nodes)
|
|
135
|
+
end
|
|
136
|
+
called.uniq.each do |name|
|
|
137
|
+
next if name == action.to_sym
|
|
138
|
+
|
|
139
|
+
tree.breadth_first_search { it.is_a?(Prism::DefNode) && it.name == name && it.receiver.nil? }&.then { walk.(it, []) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
# `file = params[:file]` and then `file.read`: the local is the upload.
|
|
143
|
+
if defn
|
|
144
|
+
locals = {}
|
|
145
|
+
seen = []
|
|
146
|
+
stack = [defn]
|
|
147
|
+
while (n = stack.pop)
|
|
148
|
+
seen << n
|
|
149
|
+
stack.concat(n.compact_child_nodes)
|
|
150
|
+
end
|
|
151
|
+
seen.grep(Prism::LocalVariableWriteNode).each do |w|
|
|
152
|
+
v = w.value
|
|
153
|
+
next unless v.is_a?(Prism::CallNode) && v.receiver.is_a?(Prism::CallNode) && v.receiver.name == :params && v.arguments&.arguments&.first.is_a?(Prism::SymbolNode)
|
|
154
|
+
|
|
155
|
+
locals[w.name] = v.arguments.arguments.first.unescaped.to_sym
|
|
156
|
+
end
|
|
157
|
+
seen.grep(Prism::CallNode).each do |c|
|
|
158
|
+
next unless %i[read original_filename path].include?(c.name) && c.receiver.is_a?(Prism::LocalVariableReadNode) && (key = locals[c.receiver.name])
|
|
159
|
+
|
|
160
|
+
found[key] = [:upload]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
found
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Path segments become pool records: `/loans/:id/renew` takes a Loan. In a
|
|
167
|
+
# nested path the parent is read from the child (`closure.branch_id`), so
|
|
168
|
+
# the two always match.
|
|
169
|
+
def path_params(path, params, lookups: {})
|
|
170
|
+
resource = path.split("/").reject { it.empty? || it.start_with?(":") }
|
|
171
|
+
segments = path.scan(/:(\w+)/).flatten.map do |key|
|
|
172
|
+
key = key.to_sym
|
|
173
|
+
[key, key == :id ? model_for(resource.reverse.find { model_for(it) }) : model_for(key)]
|
|
174
|
+
end
|
|
175
|
+
path.gsub(/:(\w+)/) do
|
|
176
|
+
key = $1.to_sym
|
|
177
|
+
if (choices = lookups[key])
|
|
178
|
+
# One file finds `:id` as a patient under /patients and as an
|
|
179
|
+
# appointment under /appointments: the segment before the param says which.
|
|
180
|
+
before = path[%r{([a-z_]+)/:#{key}\b}, 1]
|
|
181
|
+
found = choices.find { it[0].table_name == before || pool_name(it[0]).to_s == before } || choices.first
|
|
182
|
+
var = found[0].model_name.singular
|
|
183
|
+
params[var.to_sym] = found[2] || pool_name(found[0])
|
|
184
|
+
next "\#{#{var}.#{found[1]}}"
|
|
185
|
+
end
|
|
186
|
+
model = segments.assoc(key)[1]
|
|
187
|
+
child = segments.map(&:last).compact.find do |m|
|
|
188
|
+
m != model && model && m.reflect_on_all_associations(:belongs_to).any? { !it.polymorphic? && it.klass == model }
|
|
189
|
+
end
|
|
190
|
+
if child
|
|
191
|
+
fk = child.reflect_on_all_associations(:belongs_to).find { it.klass == model }.foreign_key
|
|
192
|
+
"\#{#{child.model_name.singular}.#{fk}}"
|
|
193
|
+
elsif model
|
|
194
|
+
var = model.model_name.singular
|
|
195
|
+
params[var.to_sym] = pool_name(model)
|
|
196
|
+
"\#{#{var}.#{lookup_columns[[model.name, key]] || "id"}}"
|
|
197
|
+
elsif (source = column_source(key, models))
|
|
198
|
+
var = source[0].model_name.singular
|
|
199
|
+
params[var.to_sym] = pool_name(source[0])
|
|
200
|
+
"\#{#{var}.#{source[1]}}"
|
|
201
|
+
else
|
|
202
|
+
params[key] = :TODO
|
|
203
|
+
"\#{#{key}}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A controller that finds a path's record by another column
|
|
209
|
+
# (`Location.find_by!(code: params[:location_id])`) answers 404 to an id.
|
|
210
|
+
# Keyed by model and param, from every controller.
|
|
211
|
+
def lookup_columns
|
|
212
|
+
@lookup_columns ||= Dir[Rails.root.join("app/controllers/**/*.rb")].each_with_object({}) do |file, found|
|
|
213
|
+
Prism.parse_file(file).value.breadth_first_search do |c|
|
|
214
|
+
next false unless c.is_a?(Prism::CallNode) && %i[find_by find_by!].include?(c.name) && c.receiver.is_a?(Prism::ConstantReadNode)
|
|
215
|
+
|
|
216
|
+
c.arguments&.arguments&.grep(Prism::KeywordHashNode)&.flat_map(&:elements)&.each do |pair|
|
|
217
|
+
v = pair.value
|
|
218
|
+
next unless pair.key.is_a?(Prism::SymbolNode) && v.is_a?(Prism::CallNode) && v.name == :[] &&
|
|
219
|
+
v.receiver.is_a?(Prism::CallNode) && v.receiver.name == :params && v.arguments&.arguments&.first.is_a?(Prism::SymbolNode)
|
|
220
|
+
|
|
221
|
+
found[[c.receiver.name.to_s, v.arguments.arguments.first.unescaped.to_sym]] ||= pair.key.unescaped
|
|
222
|
+
end
|
|
223
|
+
false
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# A body param a controller finds a record by
|
|
229
|
+
# (`Member.find_by!(email: params.expect(guest: [:host_email])[:host_email])`)
|
|
230
|
+
# is drawn from that record: free text names no member.
|
|
231
|
+
def param_lookups
|
|
232
|
+
@param_lookups ||= Dir[Rails.root.join("app/{controllers,api}/**/*.rb")].each_with_object({}) do |file, found|
|
|
233
|
+
Prism.parse_file(file).value.breadth_first_search do |c|
|
|
234
|
+
next false unless c.is_a?(Prism::CallNode) && %i[find_by find_by!].include?(c.name)
|
|
235
|
+
|
|
236
|
+
# A scope may stand between: `Service.online.find_by!(code: ...)`.
|
|
237
|
+
root = c.receiver
|
|
238
|
+
root = root.receiver while root.is_a?(Prism::CallNode) && root.receiver
|
|
239
|
+
next false unless root.is_a?(Prism::ConstantReadNode)
|
|
240
|
+
|
|
241
|
+
c.arguments&.arguments&.grep(Prism::KeywordHashNode)&.flat_map(&:elements)&.each do |pair|
|
|
242
|
+
next unless pair.key.is_a?(Prism::SymbolNode) && pair.value.slice.include?("params")
|
|
243
|
+
|
|
244
|
+
key = pair.value.slice.scan(/\[:(\w+)\]/).flatten.last or next
|
|
245
|
+
model = root.name.to_s.safe_constantize
|
|
246
|
+
found[key.to_sym] ||= [model, pair.key.unescaped] if model.is_a?(Class) && model < ActiveRecord::Base
|
|
247
|
+
end
|
|
248
|
+
false
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# How an endpoint file finds a path's record: `Invoice.find(params[:id])`,
|
|
254
|
+
# `current_member.invoices.find_by!(number: params[:number])`. Grape names
|
|
255
|
+
# `/guests/:id` after the URL, not the model it finds.
|
|
256
|
+
def endpoint_lookups(file)
|
|
257
|
+
return {} unless file && File.file?(file)
|
|
258
|
+
|
|
259
|
+
text = File.read(file)
|
|
260
|
+
found = []
|
|
261
|
+
text.scan(/(?:(current_\w+)\.)?([A-Za-z_]\w*)(?:\([^)]*\))?(?:\.\w+)*?(?:\.where\([^)]*\))?\.find_by!?\((\w+):\s*params\[:(\w+)\]\)/) { |owner, word, col, key| found << [key, word, col, owner] }
|
|
262
|
+
text.scan(/(?:(current_\w+)\.)?([A-Za-z_]\w*)\.find\(params\[:(\w+)\]\)/) { |owner, word, key| found << [key, word, "id", owner] }
|
|
263
|
+
found.filter_map do |key, word, col, owner|
|
|
264
|
+
model = word.match?(/\A[A-Z]/) ? word.safe_constantize : model_for(word.singularize)
|
|
265
|
+
next unless model.is_a?(Class) && model < ActiveRecord::Base && model.column_names.include?(col)
|
|
266
|
+
|
|
267
|
+
# `current_patient.appointments.find(params[:id])` finds only the
|
|
268
|
+
# caller's own records: any other answers 404.
|
|
269
|
+
[key.to_sym, [model, col, (owned_pool(owner, word, model) if owner)]]
|
|
270
|
+
end.group_by(&:first).transform_values { it.map(&:last).uniq }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# A pool of the API caller's own records, named `<table>_of_caller`, with
|
|
274
|
+
# the expression that finds them.
|
|
275
|
+
def owned_pool(owner, assoc, model)
|
|
276
|
+
caller = model_for(owner.delete_prefix("current_")) or return
|
|
277
|
+
made = api_own_records.find { |_, line, _| line.start_with?("create(:#{factories.find { it.build_class == caller }&.name},") } or return
|
|
278
|
+
finder = "#{caller}.find_by(#{made[1][/(\w+: "#{made[2]}")\)\z/, 1]})"
|
|
279
|
+
name = :"#{pool_name(model)}_of_caller"
|
|
280
|
+
owned_pools[name] = "#{finder}.#{assoc}"
|
|
281
|
+
name
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def owned_pools = @owned_pools ||= {}
|
|
285
|
+
|
|
286
|
+
# The table a pool draws from: `appointments_of_caller` draws appointments.
|
|
287
|
+
def pool_table(pool) = pool.to_s.delete_suffix("_of_caller")
|
|
288
|
+
|
|
289
|
+
# The values a model's `inclusion: { in: [...] }` accepts for a column of
|
|
290
|
+
# this name, when every model that validates it accepts the same list.
|
|
291
|
+
def inclusion_values(key)
|
|
292
|
+
lists = models.filter_map do |m|
|
|
293
|
+
v = m.validators_on(key.to_sym).grep(ActiveModel::Validations::InclusionValidator).first or next
|
|
294
|
+
list = v.options[:in] || v.options[:within]
|
|
295
|
+
list = list.to_a if list.is_a?(Range) && list.size.to_i <= 20
|
|
296
|
+
list if list.is_a?(Array) && !list.empty? && list.all? { it.is_a?(String) || it.is_a?(Integer) }
|
|
297
|
+
end.uniq
|
|
298
|
+
lists.first if lists.size == 1
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# One body entry for a param: the value expression, with its spec added to
|
|
302
|
+
# +params+.
|
|
303
|
+
def value_for(key, type, params)
|
|
304
|
+
key = key.to_s
|
|
305
|
+
if !key.end_with?("_id") && (found = param_lookups[key.to_sym]) && found[1] != key
|
|
306
|
+
var = :"#{key}_from"
|
|
307
|
+
params[var] = pool_name(found[0])
|
|
308
|
+
"#{key}: #{var}.#{found[1]}"
|
|
309
|
+
elsif key.end_with?("_id") && (model = model_for(key))
|
|
310
|
+
var = model.model_name.singular.to_sym
|
|
311
|
+
var = key.delete_suffix("_id").to_sym if params.key?(var) && params[var] != pool_name(model)
|
|
312
|
+
params[var] = pool_name(model)
|
|
313
|
+
"#{key}: #{var}.id"
|
|
314
|
+
elsif type == "Date" || key.end_with?("_on") || %w[on date day].include?(key)
|
|
315
|
+
var = key.end_with?("_on") ? key : "#{key}_on"
|
|
316
|
+
params[var.to_sym] = :local_date
|
|
317
|
+
"#{key}: #{var}.iso8601"
|
|
318
|
+
elsif %w[Time DateTime].include?(type) || key.end_with?("_at")
|
|
319
|
+
var = key.end_with?("_at") ? key : "#{key}_at"
|
|
320
|
+
params[var.to_sym] = :local_time
|
|
321
|
+
"#{key}: #{var}.iso8601"
|
|
322
|
+
elsif type == "Integer"
|
|
323
|
+
params[key.to_sym] = 0..5
|
|
324
|
+
"#{key}:"
|
|
325
|
+
elsif %w[BigDecimal Float Numeric].include?(type) && COUNTS.none? { |pattern, _| key.match?(pattern) }
|
|
326
|
+
params[key.to_sym] = ["0", "1", "12.5", "-1"]
|
|
327
|
+
"#{key}:"
|
|
328
|
+
elsif key.end_with?("_ids") && (model = model_for(key.delete_suffix("_ids")))
|
|
329
|
+
# A list of ids sends one record's id: text there is refused or finds nothing.
|
|
330
|
+
var = model.model_name.singular.to_sym
|
|
331
|
+
params[var] = pool_name(model)
|
|
332
|
+
"#{key}: [#{var}.id]"
|
|
333
|
+
elsif type&.start_with?("Array") || type&.match?(/\A\[\w+\]\z/)
|
|
334
|
+
# `Array[String]` refuses a bare word and an empty list alike.
|
|
335
|
+
params[key.to_sym] = :text
|
|
336
|
+
"#{key}: [#{key}]"
|
|
337
|
+
elsif type == "Hash"
|
|
338
|
+
# A Hash with no declared keys refuses text; empty is the one safe draw.
|
|
339
|
+
"#{key}: {}"
|
|
340
|
+
elsif %w[Boolean Grape::API::Boolean].include?(type)
|
|
341
|
+
params[key.to_sym] = 0..1
|
|
342
|
+
"#{key}: #{key} == 1"
|
|
343
|
+
elsif (source = column_source(key, models))
|
|
344
|
+
var = :"#{key}_from"
|
|
345
|
+
params[var] = pool_name(source[0])
|
|
346
|
+
"#{key}: #{var}.#{source[1]}"
|
|
347
|
+
elsif (listed = inclusion_values(key))
|
|
348
|
+
params[key.to_sym] = listed + [listed.first.is_a?(String) ? "holdfast text" : -1]
|
|
349
|
+
"#{key}:"
|
|
350
|
+
elsif (counts = COUNTS.find { |pattern, _| key.match?(pattern) }&.last)
|
|
351
|
+
params[key.to_sym] = counts
|
|
352
|
+
"#{key}:"
|
|
353
|
+
else
|
|
354
|
+
params[key.to_sym] = :text
|
|
355
|
+
"#{key}:"
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# A form field of a model's column is drawn by the column's type: text in
|
|
360
|
+
# an integer capacity or a time zone refused every draw. A string the
|
|
361
|
+
# model refuses as free text (a known time zone, a code format) is drawn
|
|
362
|
+
# from the factory's value, free text and blank.
|
|
363
|
+
COLUMN_TYPES = { integer: "Integer", boolean: "Boolean", date: "Date", datetime: "Time", decimal: "BigDecimal", float: "Float" }.freeze
|
|
364
|
+
def form_value(model, key, params)
|
|
365
|
+
col = model&.columns_hash&.[](key.to_s)
|
|
366
|
+
# A unique column drawn from records already there collides every time:
|
|
367
|
+
# the factory makes a new value per call.
|
|
368
|
+
factory = factories.find { it.build_class == model } if col
|
|
369
|
+
if factory && model.validators_on(key.to_sym).grep(ActiveRecord::Validations::UniquenessValidator).any? &&
|
|
370
|
+
(FactoryBot.attributes_for(factory.name)[key.to_sym] rescue nil).is_a?(String)
|
|
371
|
+
return "#{key}: FactoryBot.attributes_for(:#{factory.name})[:#{key}]"
|
|
372
|
+
end
|
|
373
|
+
foreign = model&.reflect_on_all_associations(:belongs_to)&.any? { it.foreign_key.to_s == key.to_s }
|
|
374
|
+
return value_for(key, nil, params) if foreign
|
|
375
|
+
# A percentage is drawn at and past its ends: 0..5 never asks whether
|
|
376
|
+
# 101 percent off is refused.
|
|
377
|
+
if col && %i[integer decimal float].include?(col.type) && key.to_s.include?("percent")
|
|
378
|
+
params[key.to_sym] = PERCENTS
|
|
379
|
+
return "#{key}:"
|
|
380
|
+
end
|
|
381
|
+
if col&.type == :integer && col.null
|
|
382
|
+
params[key.to_sym] = [nil, 0, 1, 2, 5]
|
|
383
|
+
return "#{key}:"
|
|
384
|
+
end
|
|
385
|
+
return value_for(key, COLUMN_TYPES[col&.type], params) unless col && %i[string text].include?(col.type) && !column_source(key.to_s, models)
|
|
386
|
+
|
|
387
|
+
fixed = factory_value(model, key.to_s) or return value_for(key, nil, params)
|
|
388
|
+
params[key.to_sym] = [fixed, "holdfast text", ""]
|
|
389
|
+
"#{key}:"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def factory_value(model, key)
|
|
393
|
+
factory = factories.find { it.build_class == model } or return nil
|
|
394
|
+
record = FactoryBot.build(factory.name)
|
|
395
|
+
value = record[key]
|
|
396
|
+
return nil unless value.is_a?(String) && record.valid?
|
|
397
|
+
|
|
398
|
+
record[key] = "holdfast text"
|
|
399
|
+
record.valid?
|
|
400
|
+
value if record.errors[key.to_sym].any?
|
|
401
|
+
rescue StandardError
|
|
402
|
+
nil
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# An end time drawn apart from its start broke a duration rule at nearly
|
|
406
|
+
# every draw; draw it as minutes after the start instead.
|
|
407
|
+
DURATIONS = [30, 60, 90, 120, 180, 15, 0, -30, 240, 600].freeze
|
|
408
|
+
def pair_ends!(params)
|
|
409
|
+
params.keys.each do |key|
|
|
410
|
+
stem = key.to_s[/\A(.*?)(ends?|finish(?:es)?|until|to)_at\z/, 1] or next
|
|
411
|
+
start = params.keys.find { it.to_s.match?(/\A#{Regexp.escape(stem)}(starts?|begins?|from)_at\z/) } or next
|
|
412
|
+
params[key] = Sequences::After.new(from: start, minutes: DURATIONS) if params[start] == :local_time && params[key] == :local_time
|
|
413
|
+
end
|
|
414
|
+
params
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def block_for(params, call)
|
|
418
|
+
params.empty? ? "{ #{call} }" : "{ |#{params.keys.map { "#{it}:" }.join(", ")}| #{call} }"
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def route_drafts
|
|
422
|
+
Rails.application.routes.routes.filter_map do |route|
|
|
423
|
+
verb = route.verb
|
|
424
|
+
path = route.path.spec.to_s.sub("(.:format)", "")
|
|
425
|
+
next if verb.empty? || verb == "GET" || route.defaults[:controller].nil?
|
|
426
|
+
|
|
427
|
+
controller = "#{route.defaults[:controller].camelize}Controller".safe_constantize or next
|
|
428
|
+
action = route.defaults[:action]
|
|
429
|
+
params = {}
|
|
430
|
+
url = path_params(path, params)
|
|
431
|
+
segments = path.scan(/:(\w+)/).flatten.map(&:to_sym)
|
|
432
|
+
auth = controller_auth(controller)
|
|
433
|
+
body = controller_params(controller, action).filter_map do |key, (kind, attribute)|
|
|
434
|
+
next if segments.include?(key)
|
|
435
|
+
next "#{key}: ENV[#{auth[:params][key].inspect}]" if auth[:params][key]
|
|
436
|
+
|
|
437
|
+
if kind == :nested
|
|
438
|
+
form = model_for(key)
|
|
439
|
+
"#{key}: { #{attribute.map { form_value(form, it, params) }.join(", ")} }"
|
|
440
|
+
elsif kind == :nested_list
|
|
441
|
+
form = model_for(key)
|
|
442
|
+
"#{key}: [{ #{attribute.map { form_value(form, it, params) }.join(", ")} }]"
|
|
443
|
+
elsif kind == :upload
|
|
444
|
+
# Drawn text sent as a file, so the action runs past its upload.
|
|
445
|
+
params[key] = :text
|
|
446
|
+
%(#{key}: Rack::Test::UploadedFile.new(StringIO.new(#{key}.to_s), "text/plain", original_filename: "#{key}.txt"))
|
|
447
|
+
elsif kind.is_a?(Class)
|
|
448
|
+
var = kind.model_name.singular.to_sym
|
|
449
|
+
var = key.to_s.delete_suffix("_id").to_sym if params.key?(var)
|
|
450
|
+
params[var] = pool_name(kind)
|
|
451
|
+
"#{key}: #{var}.#{attribute}"
|
|
452
|
+
else
|
|
453
|
+
value_for(key, kind == :date ? "Date" : nil, params)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
# The sign-in action itself sends the known credentials.
|
|
457
|
+
body = session_sign_in[:body] if session_sign_in && route.defaults[:controller] == session_sign_in[:controller] && action == session_sign_in[:action]
|
|
458
|
+
pair_ends!(params)
|
|
459
|
+
headers = auth[:headers].map { |h, expr| "#{h.inspect} => #{expr}" }
|
|
460
|
+
call = %(http.#{verb.downcase} "#{url}"#{", params: { #{body.join(", ")} }" unless body.empty?}#{", headers: { #{headers.join(", ")} }" unless headers.empty?})
|
|
461
|
+
call = "desk_sign_in; #{call}" if session_sign_in && (session_reader?(controller) && route.defaults[:controller] != session_sign_in[:controller])
|
|
462
|
+
Draft.new(name: :"#{route.defaults[:controller].tr("/", "_")}_#{action}", params:, body: block_for(params, call),
|
|
463
|
+
kind: :route, resource: route.defaults[:controller].tr("/", "_"), file: "#{controller.instance_method(action).source_location&.first}##{action}")
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def api_drafts
|
|
468
|
+
return [] unless defined?(Grape::API)
|
|
469
|
+
|
|
470
|
+
Grape::API.descendants.select { it.respond_to?(:routes) }.flat_map { |api| api.routes.map { [api, it] } }.uniq { |_, r| [r.request_method, r.path] }.filter_map do |api, route|
|
|
471
|
+
next if route.request_method == "GET"
|
|
472
|
+
|
|
473
|
+
# An API the app mounts under a prefix (`mount DeskAPI => "/v1"`) answers
|
|
474
|
+
# only under it; Grape's own path leaves the prefix out.
|
|
475
|
+
path = grape_mounts.fetch(api, "") + route.path.sub("(.json)", "").sub(":version", route.version.to_s)
|
|
476
|
+
# A mounted API lists its routes once more without the root's prefix.
|
|
477
|
+
next if path.include?("(")
|
|
478
|
+
|
|
479
|
+
params = {}
|
|
480
|
+
url = path_params(path, params, lookups: endpoint_lookups(route.app.source&.source_location&.first))
|
|
481
|
+
# Grape lists a nested param as "lines[sku]" beside "lines"; the draft
|
|
482
|
+
# sends the parent as one Hash (an Array of one Hash when its type is
|
|
483
|
+
# Array), each child drawn under a name of its own.
|
|
484
|
+
top = route.params.reject { |k, _| path.include?(":#{k}") || k.to_s.include?("[") }
|
|
485
|
+
children = route.params.select { |k, _| k.to_s.include?("[") }.group_by { |k, _| k.to_s[/\A[^\[]+/] }
|
|
486
|
+
body = top.map do |key, spec|
|
|
487
|
+
kids = children.delete(key.to_s)
|
|
488
|
+
unless kids
|
|
489
|
+
expr = grape_value(key, spec, params)
|
|
490
|
+
next expr unless spec.is_a?(Hash) && spec[:required] == false
|
|
491
|
+
|
|
492
|
+
# An optional param is sent or left out: a code drawn from any
|
|
493
|
+
# record is refused whenever it is sent, and leaving it out is
|
|
494
|
+
# the call most clients make.
|
|
495
|
+
params[:"#{key}_sent"] = 0..1
|
|
496
|
+
value = expr.sub(/\A#{key}:\s*/, "")
|
|
497
|
+
next "**(#{key}_sent == 1 ? { #{key}: #{value.empty? ? key : value} } : {})"
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
inner = kids.filter_map do |k, s|
|
|
501
|
+
leaf = k.to_s[/\[([^\[\]]+)\]\z/, 1] or next
|
|
502
|
+
next if k.to_s.count("[") > 1
|
|
503
|
+
if s.is_a?(Hash) && s[:values].is_a?(Array)
|
|
504
|
+
next "#{leaf}: #{grape_value("#{key}_#{leaf}", s, params).sub(/\A#{key}_#{leaf}:\s*/, "").then { it.empty? ? "#{key}_#{leaf}" : it }}"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# A child that is a column of the parent's model is drawn as the
|
|
508
|
+
# desk form draws it: `kind` from the values the model accepts.
|
|
509
|
+
form = model_for(key.to_s)
|
|
510
|
+
if form&.columns_hash&.key?(leaf)
|
|
511
|
+
drawn = {}
|
|
512
|
+
expr = form_value(form, leaf, drawn).sub(/\A#{leaf}:\s*/, "")
|
|
513
|
+
if expr.empty?
|
|
514
|
+
params[:"#{key}_#{leaf}"] = drawn.delete(leaf.to_sym)
|
|
515
|
+
expr = "#{key}_#{leaf}"
|
|
516
|
+
end
|
|
517
|
+
params.merge!(drawn)
|
|
518
|
+
next "#{leaf}: #{expr}"
|
|
519
|
+
end
|
|
520
|
+
expr = value_for("#{key}_#{leaf}", s.is_a?(Hash) ? s[:type].to_s : nil, params).sub(/\A#{key}_#{leaf}:\s*/, "")
|
|
521
|
+
"#{leaf}: #{expr.empty? ? "#{key}_#{leaf}" : expr}"
|
|
522
|
+
end
|
|
523
|
+
hash = "{ #{inner.join(", ")} }"
|
|
524
|
+
"#{key}: #{spec.is_a?(Hash) && spec[:type].to_s.start_with?("Array") ? "[#{hash}]" : hash}"
|
|
525
|
+
end
|
|
526
|
+
children.each_key do |orphan|
|
|
527
|
+
params[orphan.to_sym] = :TODO
|
|
528
|
+
body << "#{orphan}:"
|
|
529
|
+
end
|
|
530
|
+
params[:caller] = pool_name(api_caller_model) if api_caller_model
|
|
531
|
+
pair_ends!(params)
|
|
532
|
+
rest = path.sub(%r{\A/api/v\d+/}, "")
|
|
533
|
+
name = :"api_#{route.request_method.downcase}_#{rest.gsub(/[^a-z]+/, "_").delete_suffix("_")}"
|
|
534
|
+
call = %(http.#{route.request_method.downcase} "#{url}"#{", params: { #{body.join(", ")} }" unless body.empty?}, headers: auth_for(#{"caller" if api_caller_model}))
|
|
535
|
+
Draft.new(name:, params:, body: block_for(params, call), kind: :api, resource: "api_#{rest.split("/").reject { it.start_with?(":") }.slice_after { model_for(it.singularize) }.first.join("_")}",
|
|
536
|
+
file: route.app.source&.source_location&.first)
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# A Grape param with `values: [...]` draws each listed value and one it
|
|
541
|
+
# refuses; any other param is drawn by its type.
|
|
542
|
+
def grape_value(key, spec, params)
|
|
543
|
+
values = spec[:values] if spec.is_a?(Hash)
|
|
544
|
+
return value_for(key, spec.is_a?(Hash) ? spec[:type].to_s : nil, params) unless values.is_a?(Array) && values.all? { it.is_a?(String) || it.is_a?(Integer) }
|
|
545
|
+
|
|
546
|
+
params[key.to_sym] = values + [values.first.is_a?(String) ? "holdfast text" : -1]
|
|
547
|
+
"#{key}:"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def grape_mounts
|
|
551
|
+
@grape_mounts ||= Rails.application.routes.routes.each_with_object({}) do |r, out|
|
|
552
|
+
app = r.app.respond_to?(:app) ? r.app.app : r.app
|
|
553
|
+
next unless app.is_a?(Class) && app < Grape::API
|
|
554
|
+
|
|
555
|
+
out[app] = r.path.spec.to_s.sub(/\(.*\)\z/, "").delete_suffix("/")
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def job_drafts
|
|
560
|
+
ActiveJob::Base.descendants.filter_map do |job|
|
|
561
|
+
next if job.name.nil? || job.name == "ApplicationJob"
|
|
562
|
+
# Rails' own jobs (ActionMailer::MailDeliveryJob, Active Storage's)
|
|
563
|
+
# take framework arguments no pool can draw.
|
|
564
|
+
next unless job.instance_method(:perform).source_location&.first.to_s.start_with?(Rails.root.join("app").to_s)
|
|
565
|
+
|
|
566
|
+
required = job.instance_method(:perform).parameters.select { it.first == :req }.map(&:last)
|
|
567
|
+
params = {}
|
|
568
|
+
args = required.map do |arg|
|
|
569
|
+
if arg.to_s.match?(/\A(on|date|day)\z|_(on|date)\z/)
|
|
570
|
+
params[arg] = :local_date
|
|
571
|
+
next arg.to_s
|
|
572
|
+
elsif arg.to_s.match?(/\A(at|time|now)\z|_at\z/)
|
|
573
|
+
params[arg] = :local_time
|
|
574
|
+
next arg.to_s
|
|
575
|
+
end
|
|
576
|
+
if (model = model_for(arg))
|
|
577
|
+
var = model.model_name.singular.to_sym
|
|
578
|
+
params[var] = pool_name(model)
|
|
579
|
+
arg.to_s.end_with?("_id") ? "#{var}.id" : var.to_s
|
|
580
|
+
else
|
|
581
|
+
value_for(arg, nil, params).sub(/\A#{arg}: ?/, "").then { it.empty? ? arg.to_s : it }
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
Draft.new(name: job.name.underscore.to_sym, params:, body: block_for(params, "#{job.name}.perform_now(#{args.join(", ")})"),
|
|
585
|
+
kind: :job, resource: nil, file: job.instance_method(:perform).source_location&.first)
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def drafts = @drafts ||= route_drafts + api_drafts + job_drafts
|
|
590
|
+
|
|
591
|
+
# The fakes the specs install, as [client setter, fake class].
|
|
592
|
+
def fakes
|
|
593
|
+
@fakes ||= Rails.root.glob("spec/support/**/*.rb").flat_map do |file|
|
|
594
|
+
File.read(file).scan(/^\s*config\.before.*?(\w+(?:::\w+)*)\.client\s*=\s*(Fake\w+)\.new/m)
|
|
595
|
+
end.uniq
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# Methods a fake has and the real client does not, named like a failure:
|
|
599
|
+
# each becomes a `failure`. One with a required argument needs a person
|
|
600
|
+
# to say which record it fails for.
|
|
601
|
+
def failures
|
|
602
|
+
@failures ||= fakes.flat_map do |service, fake_name|
|
|
603
|
+
fake = fake_name.constantize
|
|
604
|
+
real = "#{service}::Client".safe_constantize
|
|
605
|
+
(fake.public_instance_methods(false) - (real ? real.public_instance_methods : [])).grep(FAILURE_NAME).map do |method|
|
|
606
|
+
# The probe prepends a pause hook; read the fake's own signature under it.
|
|
607
|
+
original = fake.instance_method(method)
|
|
608
|
+
original = original.super_method while original.owner != fake
|
|
609
|
+
required = original.parameters.select { it.first == :req }.map(&:last)
|
|
610
|
+
name = :"#{service.underscore}_#{method}"
|
|
611
|
+
code = required.empty? ? "failure(:#{name}) { #{service}.client.#{method} }" :
|
|
612
|
+
"# TODO: failure(:#{name}) { #{service}.client.#{method}(#{required.join(", ")}) } # which #{required.join(", ")}?"
|
|
613
|
+
Failure.new(name:, code:, service: fake_name)
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# How an API caller signs in: a header looked up as a record's column, or
|
|
619
|
+
# compared with an ENV value the setup sets.
|
|
620
|
+
# Each lookup is [header, model, column, literal conditions, bearer]: a
|
|
621
|
+
# header read straight into find_by, or first into a local (`token =
|
|
622
|
+
# headers["Authorization"].delete_prefix("Bearer ")`), with the record's
|
|
623
|
+
# other conditions (`kind: "staff"`). A lookup with conditions or a bearer
|
|
624
|
+
# prefix gets a record of its own in the setup, with a known token.
|
|
625
|
+
def api_auth
|
|
626
|
+
@api_auth ||= begin
|
|
627
|
+
text = Rails.root.glob("app/api/**/*.rb").map { File.read(it) }.join("\n")
|
|
628
|
+
chunks = text.split(/^\s*def /)
|
|
629
|
+
# A helper that reads the header (`def bearer_token = headers["Authorization"]...`)
|
|
630
|
+
# stands for it where a lookup calls the helper.
|
|
631
|
+
readers = chunks.filter_map { |c| (h = c[/headers\["([^"]+)"\]/, 1]) && [c[/\A(\w+)/, 1], [h, c.include?("Bearer")]] }.to_h
|
|
632
|
+
lookups = chunks.flat_map do |chunk|
|
|
633
|
+
locals = chunk.scan(/^\s*(\w+)\s*=\s*[^=\n]*headers\["([^"]+)"\]/).to_h
|
|
634
|
+
bearer = chunk.include?("Bearer")
|
|
635
|
+
# A scope may stand between the model and the lookup (`StaffMember.active.find_by`).
|
|
636
|
+
chunk.scan(/([A-Z]\w*)((?:\.\w+(?:\([^)]*\))?)*?)\.find_by!?\(([^)]*)\)/).filter_map do |model, chain, args|
|
|
637
|
+
where = chain[/\.where\(([^)]*)\)/, 1]
|
|
638
|
+
pairs = "#{where}, #{args}".scan(/(\w+):\s*([^,]+)/)
|
|
639
|
+
header, column = pairs.filter_map { |col, expr| (h = expr[/headers\["([^"]+)"\]/, 1] || locals[expr.strip] || readers[expr.strip[/\A(\w+)/, 1]]&.first) && [h, col] }.first
|
|
640
|
+
next unless header
|
|
641
|
+
|
|
642
|
+
bearer ||= readers.values.include?([header, true])
|
|
643
|
+
|
|
644
|
+
literal = pairs.select { |_, expr| expr.strip.match?(/\A("[^"]*"|\d+)\z/) }.to_h { |k, v| [k, v.strip] }
|
|
645
|
+
[header, model.safe_constantize, column, literal, bearer]
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
envs = chunks.flat_map do |chunk|
|
|
649
|
+
env = chunk[/ENV\.fetch\("(\w+)"/, 1] or next []
|
|
650
|
+
chunk.scan(/headers\["([^"]+)"\]/).flatten.map { [it, env, chunk.include?("Bearer")] }
|
|
651
|
+
end
|
|
652
|
+
plain, own = lookups.select { it[1] }.uniq.partition { it[3].empty? && !it[4] }
|
|
653
|
+
{ lookups: plain + own, envs: envs.uniq }
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def api_caller_model = api_auth[:lookups].find { it[3].empty? && !it[4] }&.dig(1)
|
|
658
|
+
|
|
659
|
+
# The record a bearer or conditioned lookup finds, made in the setup.
|
|
660
|
+
def api_own_records
|
|
661
|
+
api_auth[:lookups].reject { it[3].empty? && !it[4] }.filter_map do |header, model, column, literal, _|
|
|
662
|
+
factory = factories.find { it.build_class == model } or next
|
|
663
|
+
token = "sequences-#{header.downcase.gsub(/[^a-z]+/, "-")}"
|
|
664
|
+
[header, "create(:#{factory.name}, #{[*role_traits(factory), *literal.map { "#{_1}: #{_2}" }, "#{column}: #{token.inspect}"].join(", ")})", token]
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# How a controller lets a caller in, read from its own source and its app
|
|
669
|
+
# ancestors': HTTP Basic or token auth whose secrets come from ENV or a
|
|
670
|
+
# literal, a param or a header compared with an ENV value. Each ENV name
|
|
671
|
+
# is set by every area's setup (controller_envs), so the draft sends the
|
|
672
|
+
# value the app expects instead of a drawn word it always refuses.
|
|
673
|
+
ENV_READ = /ENV(?:\.fetch\(|\[)\s*["'](\w+)["']/
|
|
674
|
+
def controller_auth(controller)
|
|
675
|
+
@controller_auth ||= {}
|
|
676
|
+
@controller_auth[controller] ||= begin
|
|
677
|
+
files = controller.ancestors.grep(Class).filter_map { Object.const_source_location(it.name)&.first if it.name }
|
|
678
|
+
.select { it.to_s.start_with?(Rails.root.join("app/controllers").to_s) }.uniq
|
|
679
|
+
text = files.map { File.read(it) }.join("\n")
|
|
680
|
+
secret = ->(src) { (env = src[ENV_READ, 1]) ? (controller_envs << env; "ENV[#{env.inspect}]") : src[/["'][^"']*["']/] }
|
|
681
|
+
out = { params: {}, headers: {} }
|
|
682
|
+
if (m = text.match(/http_basic_authenticate_with\s+name:\s*([^,]+),\s*password:\s*([^,\n]+)/))
|
|
683
|
+
out[:headers]["Authorization"] = "ActionController::HttpAuthentication::Basic.encode_credentials(#{secret.(m[1])}, #{secret.(m[2])})"
|
|
684
|
+
end
|
|
685
|
+
text.split(/^\s*def /).each do |chunk|
|
|
686
|
+
envs = chunk.scan(ENV_READ).flatten
|
|
687
|
+
next if envs.empty?
|
|
688
|
+
|
|
689
|
+
if chunk.match?(/(authenticate_or_request_with|authenticate_with)_http_basic/) && envs.size >= 2
|
|
690
|
+
controller_envs.concat(envs.first(2))
|
|
691
|
+
out[:headers]["Authorization"] = "ActionController::HttpAuthentication::Basic.encode_credentials(ENV[#{envs[0].inspect}], ENV[#{envs[1].inspect}])"
|
|
692
|
+
elsif chunk.match?(/(authenticate_or_request_with|authenticate_with)_http_token/)
|
|
693
|
+
controller_envs << envs.first
|
|
694
|
+
out[:headers]["Authorization"] = "ActionController::HttpAuthentication::Token.encode_credentials(ENV[#{envs.first.inspect}])"
|
|
695
|
+
end
|
|
696
|
+
compared = chunk.lines.grep(/secure_compare|==/).join
|
|
697
|
+
compared.scan(/params(?:\[:|\.(?:require|fetch)\(:)(\w+)/).flatten.each { |k| controller_envs << envs.first; out[:params][k.to_sym] = envs.first }
|
|
698
|
+
compared.scan(/request\.headers\[["']([^"']+)["']\]/).flatten.each { |h| controller_envs << envs.first; out[:headers][h] = "ENV[#{envs.first.inspect}]" }
|
|
699
|
+
end
|
|
700
|
+
out
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def controller_envs = @controller_envs ||= []
|
|
705
|
+
|
|
706
|
+
# A desk page that answers "Sign in first" unless the session holds a
|
|
707
|
+
# staff id: the action that writes that session key (sessions#create) is
|
|
708
|
+
# the sign-in, its params are drafted like any route's, and a record it
|
|
709
|
+
# looks up by literal conditions (`kind: "staff"`) is made in the setup.
|
|
710
|
+
# Every route of a controller whose before_action reads the key signs in
|
|
711
|
+
# first, through one helper.
|
|
712
|
+
def session_key(text) = text[/session\[:(\w+)\]/, 1]
|
|
713
|
+
|
|
714
|
+
def session_reader?(controller)
|
|
715
|
+
controller._process_action_callbacks.any? do |cb|
|
|
716
|
+
next false unless cb.kind == :before && cb.filter.is_a?(Symbol) && (controller.method_defined?(cb.filter) || controller.private_method_defined?(cb.filter))
|
|
717
|
+
|
|
718
|
+
key = session_key(reads_through(controller, cb.filter))
|
|
719
|
+
key && session_sign_in && key == session_sign_in[:key]
|
|
720
|
+
end
|
|
721
|
+
rescue NameError
|
|
722
|
+
false
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# A method's body and the bodies of the controller methods it calls, one
|
|
726
|
+
# call deep: `require_staff` reads the session through `current_staff`.
|
|
727
|
+
def reads_through(controller, name, depth = 1)
|
|
728
|
+
loc = controller.instance_method(name).source_location or return ""
|
|
729
|
+
body = Prism.parse_file(loc[0]).value.breadth_first_search { it.is_a?(Prism::DefNode) && it.name == name.to_sym && it.location.start_line == loc[1] }&.slice.to_s
|
|
730
|
+
return body if depth.zero?
|
|
731
|
+
|
|
732
|
+
called = body.scan(/\b([a-z_]\w*[?!]?)/).flatten.uniq.select { |m| m != name.to_s && (controller.method_defined?(m) || controller.private_method_defined?(m)) }
|
|
733
|
+
called.reject { |m| controller.instance_method(m).source_location.nil? || !controller.instance_method(m).source_location[0].start_with?(Rails.root.to_s) }
|
|
734
|
+
.map { reads_through(controller, it, depth - 1) }.unshift(body).join("\n")
|
|
735
|
+
rescue NameError
|
|
736
|
+
""
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# Desk staff that sign in with a password: the setup makes the record with
|
|
740
|
+
# a known one, and the sign-in sends it.
|
|
741
|
+
DESK_PASSWORD = "sequences-password"
|
|
742
|
+
|
|
743
|
+
def session_sign_in
|
|
744
|
+
return @session_sign_in if defined?(@session_sign_in)
|
|
745
|
+
|
|
746
|
+
@session_sign_in = Rails.application.routes.routes.lazy.filter_map do |route|
|
|
747
|
+
next unless route.verb == "POST" && route.defaults[:controller]
|
|
748
|
+
|
|
749
|
+
controller = "#{route.defaults[:controller].camelize}Controller".safe_constantize or next
|
|
750
|
+
action = route.defaults[:action]
|
|
751
|
+
loc = controller.instance_method(action).source_location rescue next
|
|
752
|
+
tree = Prism.parse_file(loc[0]).value.breadth_first_search { it.is_a?(Prism::DefNode) && it.name.to_s == action } or next
|
|
753
|
+
# The action writes the key itself, or through a helper (`sign_in(staff)`).
|
|
754
|
+
text = reads_through(controller, action)
|
|
755
|
+
key = text[/session\[:(\w+)\]\s*=[^=]/, 1] or next
|
|
756
|
+
# A scope may stand between the model and the lookup (`StaffMember.active.find_by`).
|
|
757
|
+
line = text.lines.find { it.match?(/[A-Z]\w*(?:\.\w+(?:\([^)]*\))?)*?\.find_by!?\(/) }
|
|
758
|
+
model = line && line[/([A-Z]\w*)(?:\.\w+(?:\([^)]*\))?)*?\.find_by/, 1].safe_constantize
|
|
759
|
+
literal = line ? line.scan(/(\w+):\s*("[^"]*"|\d+)/).to_h : {}
|
|
760
|
+
# A param a lookup column reads (`email: params[:email]`) gets a known
|
|
761
|
+
# value the setup gives the record too.
|
|
762
|
+
by_param = line ? line.scan(/(\w+):\s*params(?:\[:|\.(?:require|fetch)\(:)(\w+)/).to_h { |col, k| [k.to_sym, col] } : {}
|
|
763
|
+
# The secret is checked by `authenticate(params[:password])` or by a
|
|
764
|
+
# predicate of the record's own (`staff.pin_matches?(params[:pin])`);
|
|
765
|
+
# the setup gives the record that secret through its writer.
|
|
766
|
+
password = text[/authenticate\(params(?:\[:|\.(?:require|fetch)\(:)(\w+)/, 1]&.to_sym
|
|
767
|
+
secret = password ? :password : (password = text[/\.\w+\?\(params(?:\[:|\.(?:require|fetch)\(:)(\w+)/, 1]&.to_sym)
|
|
768
|
+
password = nil unless model&.method_defined?(:"#{secret}=")
|
|
769
|
+
known = by_param.to_h { |k, col| [k, col.include?("email") ? "desk@sequences.test" : "sequences-#{col.tr("_", "-")}"] }
|
|
770
|
+
auth = controller_auth(controller)
|
|
771
|
+
found = model ? model.where(literal.transform_values { it.delete('"') }) : nil
|
|
772
|
+
keys = controller_params(controller, action).keys | known.keys | [password].compact
|
|
773
|
+
body = keys.map do |k|
|
|
774
|
+
kind, attribute = controller_params(controller, action)[k]
|
|
775
|
+
if auth[:params][k] then "#{k}: ENV[#{auth[:params][k].inspect}]"
|
|
776
|
+
elsif known[k] then "#{k}: #{known[k].inspect}"
|
|
777
|
+
elsif k == password then "#{k}: #{DESK_PASSWORD.inspect}"
|
|
778
|
+
elsif kind.is_a?(Class) then "#{k}: #{kind.name}.where(#{literal.map { "#{_1}: #{_2}" }.join(", ")}).order(:id).first&.#{attribute}.to_s"
|
|
779
|
+
else "#{k}: \"\""
|
|
780
|
+
end
|
|
781
|
+
end
|
|
782
|
+
factory = model && factories.find { it.build_class == model && it.name.to_s == model.model_name.singular }
|
|
783
|
+
attrs = literal.map { "#{_1}: #{_2}" } + by_param.map { |k, col| "#{col}: #{known[k].inspect}" } + (password ? ["#{secret}: #{DESK_PASSWORD.inspect}"] : [])
|
|
784
|
+
setup = factory && attrs.any? ? "create(:#{factory.name}, #{[*role_traits(factory), *attrs].join(", ")})" : nil
|
|
785
|
+
{ key:, action:, controller: route.defaults[:controller], path: route.path.spec.to_s.sub("(.:format)", ""), body:, setup:, found: found&.to_sql }
|
|
786
|
+
end.first
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def factories
|
|
790
|
+
@factories ||= FactoryBot.factories.to_a
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# The traits that give a signed-in record a role the app checks
|
|
794
|
+
# (`trait :supervisor` with `current_staff.supervisor?` in a guard), so the
|
|
795
|
+
# caller reaches every action instead of only the ones open to all.
|
|
796
|
+
def role_traits(factory)
|
|
797
|
+
@app_text ||= Dir[Rails.root.join("app/{controllers,api}/**/*.rb")].map { File.read(it) }.join("\n")
|
|
798
|
+
factory.defined_traits.map(&:name).select do |t|
|
|
799
|
+
factory.build_class.method_defined?(:"#{t}?") && @app_text.match?(/\.#{Regexp.escape(t)}\?/)
|
|
800
|
+
end.map { ":#{it}" }
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# A factory the setup may call: every association it declares is a bare
|
|
804
|
+
# name, so the world can hand it records it already made. A factory that
|
|
805
|
+
# derives one (`copy { association :copy, status: "on_loan" }`) encodes a
|
|
806
|
+
# state the app should reach through its own actions.
|
|
807
|
+
def seedable(factory)
|
|
808
|
+
names = factories.map(&:name)
|
|
809
|
+
decls = factory.definition.declarations.to_a
|
|
810
|
+
assocs = decls.select { names.include?(it.name) || it.is_a?(FactoryBot::Declaration::Association) }
|
|
811
|
+
derived = assocs.reject { it.is_a?(FactoryBot::Declaration::Implicit) || plain_association?(it) }
|
|
812
|
+
return nil if derived.any? { encodes_state?(it) }
|
|
813
|
+
|
|
814
|
+
# A parent the factory makes with an identity of its own (a staff
|
|
815
|
+
# member whose role is "practitioner") is left to the factory.
|
|
816
|
+
(assocs - derived).map(&:name)
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def encodes_state?(decl)
|
|
820
|
+
text = case decl
|
|
821
|
+
when FactoryBot::Declaration::Association then decl.instance_variable_get(:@overrides).keys.join(" ") + " " + decl.instance_variable_get(:@traits).join(" ")
|
|
822
|
+
when FactoryBot::Declaration::Dynamic
|
|
823
|
+
file, line = decl.instance_variable_get(:@block)&.source_location
|
|
824
|
+
file && File.file?(file) ? File.readlines(file)[line - 1].to_s : "status"
|
|
825
|
+
else "status"
|
|
826
|
+
end
|
|
827
|
+
text.match?(/status|state/)
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# `branch { association :branch }` and `association :branch` name the
|
|
831
|
+
# record as a bare `branch` does: the world can hand it one it made. Only
|
|
832
|
+
# an override or a trait (`association :copy, status: "on_loan"`) encodes
|
|
833
|
+
# a state.
|
|
834
|
+
def plain_association?(decl)
|
|
835
|
+
case decl
|
|
836
|
+
when FactoryBot::Declaration::Association
|
|
837
|
+
decl.instance_variable_get(:@overrides).empty? && decl.instance_variable_get(:@traits).empty? &&
|
|
838
|
+
decl.instance_variable_get(:@factory_name) == decl.name
|
|
839
|
+
when FactoryBot::Declaration::Dynamic
|
|
840
|
+
block = decl.instance_variable_get(:@block)
|
|
841
|
+
file, line = block&.source_location
|
|
842
|
+
text = file && File.file?(file) ? File.readlines(file)[line - 1].to_s : ""
|
|
843
|
+
text.match?(/\b#{decl.name}\s*\{\s*association\s*\(?\s*:#{decl.name}\s*\)?\s*\}/)
|
|
844
|
+
else false
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# The world each area starts from: two of each root record, members three,
|
|
849
|
+
# and each child made from records already there, every pairing once.
|
|
850
|
+
def setup_lines(inserted, wanted)
|
|
851
|
+
made = {}
|
|
852
|
+
lines = []
|
|
853
|
+
needed = Set.new
|
|
854
|
+
deps = ->(f) { Array(seedable(f)) | named_parents(f) }
|
|
855
|
+
grow = ->(f) { needed.add?(f.name) && deps.(f).each { |a| factories.find { it.name == a }&.then { grow.(it) } } }
|
|
856
|
+
factories.select { seedable(it) && wanted.include?(it.build_class.table_name) }.each { grow.(it) }
|
|
857
|
+
pending = factories.select { seedable(it) && needed.include?(it.name) }
|
|
858
|
+
until pending.empty?
|
|
859
|
+
ready = pending.select { |f| deps.(f).all? { made.key?(it) } }
|
|
860
|
+
break if ready.empty?
|
|
861
|
+
|
|
862
|
+
ready.each do |factory|
|
|
863
|
+
pending.delete(factory)
|
|
864
|
+
model = factory.build_class
|
|
865
|
+
assocs = seedable(factory)
|
|
866
|
+
next if assocs.any? && inserted.include?(model.table_name)
|
|
867
|
+
|
|
868
|
+
var = factory.name.to_s.pluralize
|
|
869
|
+
limits = capacity_args(model, factory) + distinct_times(model)
|
|
870
|
+
if assocs.empty? && limits.empty?
|
|
871
|
+
traits = factory.definition.defined_traits.map(&:name)
|
|
872
|
+
# Each trait in turn (a host, then a manager), so an action only
|
|
873
|
+
# one kind of record may take is reached.
|
|
874
|
+
lines << (traits.empty? ? "#{var} = create_list(:#{factory.name}, world_size(:#{var}, #{root_sizes(factory)}))" :
|
|
875
|
+
"#{var} = Array.new(world_size(:#{var}, #{root_sizes(factory)})) { |i| create(:#{factory.name}, *#{[[], *traits.map { [it.to_sym] }].inspect}[i % #{traits.size + 1}]) }")
|
|
876
|
+
else
|
|
877
|
+
sizes = assocs.map { made[it] }
|
|
878
|
+
most = sizes.inject(1) { |a, b| a * b }.clamp(2, 6)
|
|
879
|
+
# Strides from the sizes drawn this case, and no more children than
|
|
880
|
+
# parent pairings, so each child pairs its parents once.
|
|
881
|
+
picks = assocs.each_with_index.map do |a, i|
|
|
882
|
+
stride = assocs[0...i].map { "#{it.to_s.pluralize}.size" }.join(" * ").presence || "1"
|
|
883
|
+
"#{a}: #{a.to_s.pluralize}[i / (#{stride}) % #{a.to_s.pluralize}.size]"
|
|
884
|
+
end
|
|
885
|
+
# A parent a unique index holds to one child (one open membership
|
|
886
|
+
# per member) is used once: each child takes the next parent, and
|
|
887
|
+
# there are no more children than parents.
|
|
888
|
+
held = conn_unique_columns(model)
|
|
889
|
+
once = assocs.select { held.include?(model.reflect_on_association(it)&.foreign_key.to_s) }
|
|
890
|
+
# A parent that `has_one` of these takes one only if it has none
|
|
891
|
+
# yet: a trait (an insured patient) may have made it one already.
|
|
892
|
+
single = assocs.select do |a|
|
|
893
|
+
parent = model.reflect_on_association(a)&.klass rescue nil
|
|
894
|
+
parent&.reflect_on_all_associations(:has_one)&.any? { !it.through_reflection? && !it.options[:as] && (it.klass rescue nil) == model }
|
|
895
|
+
end
|
|
896
|
+
single.each do |a|
|
|
897
|
+
fk = model.reflect_on_association(a).foreign_key
|
|
898
|
+
lines << "free_#{a.to_s.pluralize} = #{a.to_s.pluralize}.reject { #{model.name}.exists?(#{fk}: it.id) }"
|
|
899
|
+
end
|
|
900
|
+
once |= single
|
|
901
|
+
list = ->(a) { single.include?(a) ? "free_#{a.to_s.pluralize}" : a.to_s.pluralize }
|
|
902
|
+
once.each { |a| picks[assocs.index(a)] = "#{a}: #{list.(a)}[i]" }
|
|
903
|
+
# A payment of kind "invoice" belongs to an invoice, and to that
|
|
904
|
+
# invoice's patient: a world where it has neither is one the app
|
|
905
|
+
# never makes.
|
|
906
|
+
named_parents(factory).each do |n|
|
|
907
|
+
pick = "#{n.to_s.pluralize}[i % #{n.to_s.pluralize}.size]"
|
|
908
|
+
parent = model.reflect_on_association(n).klass
|
|
909
|
+
assocs.each_with_index { |a, j| picks[j] = "#{a}: #{pick}.#{a}" if parent.reflect_on_association(a)&.macro == :belongs_to }
|
|
910
|
+
picks << "#{n}: #{pick}"
|
|
911
|
+
end
|
|
912
|
+
range = assocs.empty? ? root_sizes(factory) : "1..#{most}"
|
|
913
|
+
count = "world_size(:#{var}, #{range})"
|
|
914
|
+
count = "[#{count}, #{(once.any? ? once : assocs).map { "#{list.(it)}.size" }.join(once.any? ? ", " : " * ")}].min" if assocs.any?
|
|
915
|
+
lines << "#{var} = Array.new(#{count}) { |i| create(:#{factory.name}, #{(picks + limits).join(", ")}) }"
|
|
916
|
+
lines.concat(settle_lines(var, model, made.keys.map { it.to_s.pluralize }))
|
|
917
|
+
end
|
|
918
|
+
# The largest each can be: a child pairs with every parent up to it.
|
|
919
|
+
made[factory.name] = assocs.empty? ? root_sizes(factory).max : sizes.inject(1) { |a, b| a * b }.clamp(2, 6)
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
lines
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# The optional parents a factory's own values name (kind "invoice" and
|
|
926
|
+
# `belongs_to :invoice, optional: true`), which the setup fills.
|
|
927
|
+
def named_parents(factory)
|
|
928
|
+
values = (FactoryBot.attributes_for(factory.name) rescue {}).values.grep(String)
|
|
929
|
+
names = factories.map(&:name)
|
|
930
|
+
factory.build_class.reflect_on_all_associations(:belongs_to)
|
|
931
|
+
.select { it.options[:optional] && !it.polymorphic? && values.include?(it.name.to_s) && names.include?(it.name) }.map(&:name)
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
# World sizes are drawn per case, so capacity pressure and an empty
|
|
935
|
+
# waitlist both happen: members 2 to 8, other roots 1 to 3.
|
|
936
|
+
def root_sizes(factory) = factory.name == :member ? (2..8) : (1..3)
|
|
937
|
+
|
|
938
|
+
# A capacity column (seats, capacity, a limit) drawn 1 to 3 per record.
|
|
939
|
+
# A capacity column some records must leave blank (seats only on a team
|
|
940
|
+
# plan) would make every drafted record invalid, so it is drawn only when
|
|
941
|
+
# the factory's record stays valid with it set.
|
|
942
|
+
def capacity_args(model, factory)
|
|
943
|
+
model.columns.select { it.type == :integer && it.name.match?(/capacity|seats|limit|\Amax_|slots|quota/) }
|
|
944
|
+
.select { |col| (FactoryBot.build(factory.name, col.name => 1).valid? rescue true) }
|
|
945
|
+
.map { "#{it.name}: world_size(:\"#{model.model_name.plural}.#{it.name}\", 1..3)" }
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# The columns a unique index holds alone, partial ones included: a
|
|
949
|
+
# partial index still forbids two rows its condition selects, and the
|
|
950
|
+
# factory's defaults usually meet that condition.
|
|
951
|
+
def conn_unique_columns(model)
|
|
952
|
+
indexes = ActiveRecord::Base.connection.indexes(model.table_name).select(&:unique)
|
|
953
|
+
alone = indexes.select { Array(it.columns).size == 1 }.map { Array(it.columns).first }
|
|
954
|
+
# One parent with a day or a kind (`member_id, visited_on`): the factory
|
|
955
|
+
# gives every record the same day, so each parent is used once.
|
|
956
|
+
shared = indexes.filter_map { |i| ids = Array(i.columns).grep(/_id\z/); ids.first if Array(i.columns).size > 1 && ids.one? }
|
|
957
|
+
(alone + shared).uniq
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
# A time column a unique index or an exclusion constraint holds (one start
|
|
961
|
+
# per member) collides when every record takes the factory's one default,
|
|
962
|
+
# and a collision in the world is the setup's, not the app's. Each record
|
|
963
|
+
# gets its own hour instead.
|
|
964
|
+
def distinct_times(model)
|
|
965
|
+
conn = ActiveRecord::Base.connection
|
|
966
|
+
held = conn.indexes(model.table_name).select(&:unique).flat_map { Array(it.columns) }
|
|
967
|
+
held += conn.exclusion_constraints(model.table_name).flat_map { it.expression.scan(/\w+/) } if conn.respond_to?(:exclusion_constraints)
|
|
968
|
+
times = model.columns.select { %i[datetime timestamp].include?(it.type) && held.include?(it.name) }
|
|
969
|
+
# An end held with its start is left to the factory, which derives it
|
|
970
|
+
# from the start (`starts_at + service.duration`); the same hour for both
|
|
971
|
+
# makes an empty slot.
|
|
972
|
+
times = times.reject { it.name.match?(/\A(.*_)?(ends?|finish(es)?|until|to)_at\z/) } if times.size > 1
|
|
973
|
+
times.map { "#{it.name}: Time.current.beginning_of_hour + (i + 1).hours" }
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
# Tables an action of the area inserts into as its own resource (its name
|
|
977
|
+
# names the table, as loans_create names loans). Those the setup leaves to
|
|
978
|
+
# the app. A table inserted only as a side effect of another resource's
|
|
979
|
+
# action (a kiosk return that requests a transfer) is seeded too: that
|
|
980
|
+
# action needs a world of its own first, so without seeding, the actions
|
|
981
|
+
# that draw from the table would never run.
|
|
982
|
+
def made_by_own_actions(selected, stats)
|
|
983
|
+
selected.flat_map do |a|
|
|
984
|
+
stats[a.name][:writes].keys.grep(/\Ainsert /).map { it.split.last }
|
|
985
|
+
.select { |t| a.name.to_s.include?(t) || a.name.to_s.include?(t.singularize) }
|
|
986
|
+
end.uniq - copied_from(selected)
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
# A table an action draws a record or a value from (`practitioner:
|
|
990
|
+
# :practitioners`, `guest_email_from: :guest_passes`)
|
|
991
|
+
# is made in the setup even when the area inserts into it: an empty pool
|
|
992
|
+
# leaves the action never drawn.
|
|
993
|
+
def copied_from(selected) = selected.flat_map { |d| d.params.values.grep(Symbol).map { pool_table(it) } }.uniq
|
|
994
|
+
|
|
995
|
+
def pool_tables(selected) = selected.flat_map { it.params.values }.grep(Symbol).map { pool_table(it) }
|
|
996
|
+
|
|
997
|
+
# What the actions read in the probe (a member's active membership) is
|
|
998
|
+
# made too, or each check-in is refused for a record the area never has.
|
|
999
|
+
def setup_tables(selected, stats) = pool_tables(selected) | selected.flat_map { stats.dig(it.name, :reads)&.keys || [] }
|
|
1000
|
+
|
|
1001
|
+
def probe(selected)
|
|
1002
|
+
source = definition_source("probe", selected, {}, refusals: [], failures: [], setup: setup_lines([], models.map(&:table_name)))
|
|
1003
|
+
definition = Sequences::Definition.new("probe", shared: false)
|
|
1004
|
+
definition.instance_eval(source.lines[1..-2].join)
|
|
1005
|
+
Sequences.probe(definition)
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
# The table a resource is about: `room_bookings` -> room_bookings,
|
|
1009
|
+
# `api_staff_closures` -> closures; nil for `api_kiosk` or a webhook.
|
|
1010
|
+
def primary(resource)
|
|
1011
|
+
word = resource.to_s.split("_").each_index.lazy.map { |i| resource.to_s.split("_")[i..].join("_") }.find { model_for(it.singularize) }
|
|
1012
|
+
word && model_for(word.singularize).table_name
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
# Constants a source file names, directly or in an app class it names (one
|
|
1016
|
+
# hop): where the probe saw no write or no outside call, this places a job
|
|
1017
|
+
# or an endpoint and says which fakes it can reach.
|
|
1018
|
+
# A route's file is `path#action`: only that method is read.
|
|
1019
|
+
def static_constants(file)
|
|
1020
|
+
path, method = file.to_s.split("#", 2)
|
|
1021
|
+
return [] unless File.exist?(path.to_s)
|
|
1022
|
+
|
|
1023
|
+
@static ||= {}
|
|
1024
|
+
@static[file] ||= begin
|
|
1025
|
+
names = ->(p) { [].tap { |acc| walk_constants(Prism.parse_file(p).value, acc) } }
|
|
1026
|
+
tree = Prism.parse_file(path).value
|
|
1027
|
+
tree = tree.breadth_first_search { it.is_a?(Prism::DefNode) && it.name.to_s == method } || tree if method
|
|
1028
|
+
direct = [].tap { walk_constants(tree, it) }
|
|
1029
|
+
hop = direct.uniq.filter_map { it.safe_constantize }.select { it.is_a?(Module) && !(it.is_a?(Class) && it < ActiveRecord::Base) }
|
|
1030
|
+
.filter_map { Object.const_source_location(it.name)&.first }.select { it.start_with?(Rails.root.join("app").to_s) }
|
|
1031
|
+
(direct + hop.uniq.flat_map(&names)).uniq
|
|
1032
|
+
end
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
def static_tables(file)
|
|
1036
|
+
static_constants(file).filter_map { it.safe_constantize }.select { it.is_a?(Class) && it < ActiveRecord::Base && !it.abstract_class? }
|
|
1037
|
+
.map(&:table_name).tally
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
def walk_constants(node, acc)
|
|
1041
|
+
acc << node.slice if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
1042
|
+
node.compact_child_nodes.each { walk_constants(it, acc) } unless node.is_a?(Prism::ConstantPathNode)
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
# Areas: resources about the same table form one area (`holds` and
|
|
1046
|
+
# `api_holds`). A resource about no table joins the area whose table it
|
|
1047
|
+
# writes most (or names most, when the probe saw it write nothing). Each
|
|
1048
|
+
# area then gets the actions that make the records its pools take, and is
|
|
1049
|
+
# filled, jobs first, with the actions that write its table, up to
|
|
1050
|
+
# MAX_ACTIONS.
|
|
1051
|
+
def areas(stats)
|
|
1052
|
+
written = ->(d) { stats[d.name][:writes].keys.map { it.split.last }.uniq }
|
|
1053
|
+
inserts = ->(d, t) { stats[d.name][:writes]["insert #{t}"] }
|
|
1054
|
+
touches = ->(d) { written.(d).then { it.empty? || d.kind == :job ? it | static_tables(d.file).keys : it } }
|
|
1055
|
+
seeds = drafts.select(&:resource).group_by { primary(it.resource) || "~#{it.resource}" }
|
|
1056
|
+
areas = seeds.reject { |k, _| k.start_with?("~") }.map { |table, ds| [table, ds.dup] }.to_h
|
|
1057
|
+
seeds.select { |k, _| k.start_with?("~") }.each do |key, ds|
|
|
1058
|
+
counts = ds.flat_map(&written).tally
|
|
1059
|
+
counts = ds.map { static_tables(it.file) }.inject({}) { |a, b| a.merge(b) { |_, x, y| x + y } } if counts.empty?
|
|
1060
|
+
target = counts.select { |t, _| areas.key?(t) }.max_by { |t, n| [n, t] }&.first
|
|
1061
|
+
if target then areas[target].concat(ds)
|
|
1062
|
+
else areas[key.delete_prefix("~")] = ds.dup
|
|
1063
|
+
end
|
|
1064
|
+
end
|
|
1065
|
+
seeded = factories.select { seedable(it) }.map { it.build_class.table_name }
|
|
1066
|
+
# The action that made the most rows of each pool no factory seeds, so a
|
|
1067
|
+
# pool of loans has a loans_create to fill it.
|
|
1068
|
+
makers = lambda do |selected|
|
|
1069
|
+
needed = selected.flat_map { it.params.values }.grep(Symbol).map { pool_table(it) }.uniq - seeded
|
|
1070
|
+
needed.filter_map do |pool|
|
|
1071
|
+
next if selected.any? { inserts.(it, pool).positive? }
|
|
1072
|
+
|
|
1073
|
+
drafts.max_by { inserts.(it, pool) }.then { it if inserts.(it, pool).positive? }
|
|
1074
|
+
end.uniq
|
|
1075
|
+
end
|
|
1076
|
+
# An action the probe saw refused for a state ("Only a checked in
|
|
1077
|
+
# appointment can be completed") never runs in an area that cannot reach
|
|
1078
|
+
# the state. The action whose name the refusal says (check_ins) joins it.
|
|
1079
|
+
stem = ->(word) { word.downcase.sub(/(ed|ing|s)\z/, "") }
|
|
1080
|
+
enablers = lambda do |chosen|
|
|
1081
|
+
records = chosen.flat_map { it.params.values }.grep(Symbol).map { stem.(pool_table(it).singularize) }
|
|
1082
|
+
# The probe walks every action at once, so it may have reached the state
|
|
1083
|
+
# there: any refusal it saw counts.
|
|
1084
|
+
texts = chosen.flat_map { stats[it.name][:refusals] || [] }
|
|
1085
|
+
texts.flat_map do |text|
|
|
1086
|
+
said = text.downcase.scan(/[a-z]+/).map(&stem)
|
|
1087
|
+
drafts.reject { chosen.include?(it) }.select do |d|
|
|
1088
|
+
next false unless stats[d.name][:outcomes].keys.any? { !it.include?("refused") }
|
|
1089
|
+
|
|
1090
|
+
tokens = d.kind == :api ? d.name.to_s.split("_id_", 2)[1].to_s.split("_") : d.resource.to_s.split("_")
|
|
1091
|
+
(tokens.map(&stem) - records).reverse.take_while { said.include?(it) }.any? { it.size >= 4 }
|
|
1092
|
+
end
|
|
1093
|
+
end.uniq
|
|
1094
|
+
end
|
|
1095
|
+
placed = drafts
|
|
1096
|
+
result = areas.map do |table, ds|
|
|
1097
|
+
selected = ds + makers.(ds)
|
|
1098
|
+
others = drafts.reject { selected.include?(it) }.select { touches.(it).include?(table) }
|
|
1099
|
+
others = others.sort_by { |d| [d.kind == :job ? 0 : 1, -stats[d.name][:writes].select { |k, _| k.end_with?(" #{table}") }.values.sum, d.name.to_s] }
|
|
1100
|
+
others.each do |d|
|
|
1101
|
+
break if selected.size >= MAX_ACTIONS
|
|
1102
|
+
|
|
1103
|
+
selected += [d] + makers.(selected + [d])
|
|
1104
|
+
end
|
|
1105
|
+
selected += enablers.(selected)
|
|
1106
|
+
[table, selected, ([table] + selected.flat_map(&written)).uniq]
|
|
1107
|
+
end
|
|
1108
|
+
# An action no area took (a job that only reaches a table no resource is
|
|
1109
|
+
# about) joins the area that writes most of what it touches.
|
|
1110
|
+
placed.each do |d|
|
|
1111
|
+
next if result.any? { |_, ds, _| ds.include?(d) }
|
|
1112
|
+
|
|
1113
|
+
best = result.max_by { |_, _, own| (touches.(d) & own).size }
|
|
1114
|
+
best[1] << d if best && (touches.(d) & best[2]).any?
|
|
1115
|
+
end
|
|
1116
|
+
result
|
|
1117
|
+
end
|
|
1118
|
+
|
|
1119
|
+
# A desk route and an API endpoint that make or change the same record:
|
|
1120
|
+
# `holds_create` and POST /api/v1/holds, `fines_pay` and POST
|
|
1121
|
+
# /api/v1/fines/:id/pay. Matched by the table both are about and the verb:
|
|
1122
|
+
# the probe often sees one of the two only refused, so writes miss pairs.
|
|
1123
|
+
def door_key(d)
|
|
1124
|
+
if d.kind == :route
|
|
1125
|
+
action = d.name.to_s.delete_prefix("#{d.resource}_")
|
|
1126
|
+
{ "create" => :create, "destroy" => :destroy }.fetch(action, action.to_sym)
|
|
1127
|
+
else
|
|
1128
|
+
verb, last = d.name.to_s[/\Aapi_(\w+?)_/, 1], d.body[%r{"/api/v\d+/[^"]*?([a-z_]+)"}, 1]
|
|
1129
|
+
# `POST /appointments/:id/visit` makes a visit, not an appointment.
|
|
1130
|
+
made = model_for(last.to_s.singularize)
|
|
1131
|
+
verb == "delete" ? :destroy : (made && made.table_name == primary(d.resource) ? :create : last&.to_sym)
|
|
1132
|
+
end
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
def twins(selected, stats)
|
|
1136
|
+
return [] if stats.empty? # the probe itself
|
|
1137
|
+
selected.select { it.kind == :route }.flat_map do |desk|
|
|
1138
|
+
table = primary(desk.resource) or next []
|
|
1139
|
+
selected.select { it.kind == :api && primary(it.resource) == table && door_key(it) == door_key(desk) }
|
|
1140
|
+
.map { [desk.name, it.name, table] }
|
|
1141
|
+
end
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
def definition_source(name, selected, stats, refusals:, failures:, setup: [], note: nil)
|
|
1145
|
+
pools = selected.flat_map { it.params.values }.grep(Symbol).reject { %i[TODO local_date local_time text].include?(it) }.uniq
|
|
1146
|
+
reached = ->(d) { (stats.dig(d.name, :outside) || []) | fakes.select { |service, _| static_constants(d.file).include?(service) }.map(&:last) }
|
|
1147
|
+
armed = failures.select { |f| f.code.start_with?("failure") && selected.any? { reached.(it).include?(f.service) } }
|
|
1148
|
+
out = +"Sequences.define(#{name.inspect}) do\n"
|
|
1149
|
+
out << " # #{note}\n" if note
|
|
1150
|
+
out << " setup do\n"
|
|
1151
|
+
fakes.each { |service, fake| out << " #{service}.client = #{fake}.new\n" }
|
|
1152
|
+
(api_auth[:envs].map { it[1] } | controller_envs).uniq.each { |env| out << " ENV[#{env.inspect}] = \"sequences-#{env.downcase.tr("_", "-")}\"\n" }
|
|
1153
|
+
signs_in = session_sign_in && selected.any? { it.body.include?("desk_sign_in") || it.body.include?(session_sign_in[:path].inspect) }
|
|
1154
|
+
out << " #{session_sign_in[:setup]}\n" if signs_in && session_sign_in[:setup]
|
|
1155
|
+
# The caller's own record heads the list of its kind when the setup
|
|
1156
|
+
# makes one, so the records made from that list (its membership, its
|
|
1157
|
+
# appointments) always include one of its own.
|
|
1158
|
+
own = selected.any? { it.kind == :api } ? api_own_records : []
|
|
1159
|
+
setup = setup.flat_map do |line|
|
|
1160
|
+
joined = own.select { |_, made, _| line.start_with?("#{made[/create\(:(\w+)/, 1].pluralize} = ") }
|
|
1161
|
+
own -= joined
|
|
1162
|
+
[line, *joined.map { |_, made, _| "#{made[/create\(:(\w+)/, 1].pluralize}.unshift(#{made})" }]
|
|
1163
|
+
end
|
|
1164
|
+
own.each { out << " #{it[1]}\n" }
|
|
1165
|
+
setup.each { out << " #{it}\n" }
|
|
1166
|
+
out << " end\n\n"
|
|
1167
|
+
pools.each { out << " pool(:#{it}) { #{owned_pools[it] || pool_table(it).classify}.order(:id) }\n" }
|
|
1168
|
+
out << "\n"
|
|
1169
|
+
unless refusals.empty?
|
|
1170
|
+
out << " # Errors the probe saw an action raise on purpose (classes the app defines).\n"
|
|
1171
|
+
out << " # Delete one that must never escape; any other error fails the run.\n"
|
|
1172
|
+
out << " refusals #{refusals.join(", ")}\n\n"
|
|
1173
|
+
end
|
|
1174
|
+
out << " outside_calls #{fakes.map(&:last).join(", ")}\n" unless fakes.empty?
|
|
1175
|
+
out << " clock start: [2026, 3, 7, 10, 0, 0], zones: [\"Pacific/Auckland\", \"America/Los_Angeles\"]\n\n"
|
|
1176
|
+
helper_lines = []
|
|
1177
|
+
if selected.any? { it.kind == :api }
|
|
1178
|
+
own = api_own_records
|
|
1179
|
+
headers = api_auth[:lookups].filter_map do |h, model, col, literal, bearer|
|
|
1180
|
+
next if own.any? { it[0] == h }
|
|
1181
|
+
|
|
1182
|
+
"#{h.inspect} => #{model == api_caller_model ? "caller" : "#{model}.order(:id).first"}&.#{col}.to_s"
|
|
1183
|
+
end
|
|
1184
|
+
headers += own.map { |h, _, token| "#{h.inspect} => #{(api_auth[:lookups].find { it[0] == h }&.last ? "Bearer #{token}" : token).inspect}" }
|
|
1185
|
+
headers += api_auth[:envs].map { |h, env, bearer| "#{h.inspect} => #{bearer ? "\"Bearer \#{ENV[#{env.inspect}]}\"" : "ENV[#{env.inspect}]"}" }
|
|
1186
|
+
headers.uniq! { it[/\A"[^"]+"/] }
|
|
1187
|
+
helper_lines << " def auth_for(caller = nil) = { #{headers.join(", ")} }"
|
|
1188
|
+
end
|
|
1189
|
+
if signs_in
|
|
1190
|
+
helper_lines << " # The desk pages need a staff session; #{session_sign_in[:path]} makes one."
|
|
1191
|
+
helper_lines << " def desk_sign_in = http.post(#{session_sign_in[:path].inspect}, params: { #{session_sign_in[:body].join(", ")} })"
|
|
1192
|
+
end
|
|
1193
|
+
out << " helpers do\n#{helper_lines.join("\n")}\n end\n\n" unless helper_lines.empty?
|
|
1194
|
+
shown = failures.select { |f| selected.any? { reached.(it).include?(f.service) } }
|
|
1195
|
+
shown.each { out << " #{it.code}\n" }
|
|
1196
|
+
out << "\n" unless shown.empty?
|
|
1197
|
+
twins(selected, stats).each { |a, b, t| out << " twins :#{a}, :#{b}, table: :#{t}\n" }
|
|
1198
|
+
selected.each do |d|
|
|
1199
|
+
spec = d.params.map { |k, v| "#{k}: #{v.is_a?(Symbol) ? ":#{v}" : v.inspect}" }
|
|
1200
|
+
fs = armed.select { reached.(d).include?(it.service) }.map(&:name)
|
|
1201
|
+
spec << "failures: %i[#{fs.join(" ")}]" unless fs.empty?
|
|
1202
|
+
spec << "pause_at: :batch_load" if d.kind == :job
|
|
1203
|
+
fresh = d.body.to_s.scan(/(\w+): FactoryBot\.attributes_for/).flatten.uniq
|
|
1204
|
+
spec << "fresh: %i[#{fresh.join(" ")}]" if fresh.any?
|
|
1205
|
+
out << " action(#{[":#{d.name}", *spec].join(", ")}) #{d.body}\n"
|
|
1206
|
+
end
|
|
1207
|
+
out << "end\n"
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
def app_refusals(stats, selected)
|
|
1211
|
+
selected.flat_map { stats[it.name][:errors].keys }.uniq.select do |name|
|
|
1212
|
+
klass = name.safe_constantize
|
|
1213
|
+
klass.is_a?(Class) && klass < StandardError && Object.const_source_location(name)&.first.to_s.start_with?(Rails.root.join("app").to_s)
|
|
1214
|
+
end.sort
|
|
1215
|
+
end
|
|
1216
|
+
|
|
1217
|
+
def spec_file(name, source)
|
|
1218
|
+
<<~RUBY
|
|
1219
|
+
require "rails_helper"
|
|
1220
|
+
require ENV.fetch("SEQUENCES_LIB")
|
|
1221
|
+
require_relative "invariants"
|
|
1222
|
+
|
|
1223
|
+
# Drafted by propose.rb. Edit freely: the setup world, a pool (for
|
|
1224
|
+
# example `Loan.active`), a TODO. Invariants for every area go in invariants.rb.
|
|
1225
|
+
#{source.chomp}
|
|
1226
|
+
|
|
1227
|
+
RSpec.describe "#{name} sequences" do
|
|
1228
|
+
self.use_transactional_tests = false
|
|
1229
|
+
|
|
1230
|
+
it "holds its invariants in any order, interleaving and time zone" do
|
|
1231
|
+
Sequences.explore(Sequences[#{name.inspect}], test_cases: Integer(ENV.fetch("SEQ_CASES", "100")), seed: ENV["SEQ_SEED"]&.to_i)
|
|
1232
|
+
end
|
|
1233
|
+
end
|
|
1234
|
+
RUBY
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
def statuses(model)
|
|
1238
|
+
return [] unless model.column_names.include?("status")
|
|
1239
|
+
|
|
1240
|
+
inclusion = model.validators_on(:status).grep(ActiveModel::Validations::InclusionValidator).first&.options&.[](:in)
|
|
1241
|
+
Array(inclusion || (model.const_get(:STATUSES, false) if model.const_defined?(:STATUSES, false))).map(&:to_s)
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
SETTLED = /\A(paid|settled|completed?|captured|succeeded)\z/
|
|
1245
|
+
AMOUNT = /_cents\z|\Aamount\z/
|
|
1246
|
+
|
|
1247
|
+
# Money conserved: a record with an amount and a status, and its payments
|
|
1248
|
+
# (or charges) with an amount and a status. What a settled record says it
|
|
1249
|
+
# took must be what was captured for it, and nothing for any other status.
|
|
1250
|
+
MoneyRule = Data.define(:model, :amount, :settled, :assoc, :child, :paid, :captured, :kind) do
|
|
1251
|
+
def name = "#{model.model_name.singular}_#{amount}_matches_#{assoc.name}_captured"
|
|
1252
|
+
def owner = child.reflect_on_all_associations(:belongs_to).find { it.foreign_key.to_s == assoc.foreign_key.to_s }&.name
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
def money_rules
|
|
1256
|
+
@money_rules ||= models.flat_map do |model|
|
|
1257
|
+
amount = model.column_names.find { it.match?(AMOUNT) } or next []
|
|
1258
|
+
settled = statuses(model).grep(SETTLED)
|
|
1259
|
+
next [] if settled.empty?
|
|
1260
|
+
|
|
1261
|
+
model.reflect_on_all_associations(:has_many).filter_map do |assoc|
|
|
1262
|
+
next unless assoc.name.to_s.match?(/payment|charge|capture|transaction/) && !assoc.options[:through]
|
|
1263
|
+
|
|
1264
|
+
child = assoc.klass
|
|
1265
|
+
paid = child.column_names.find { it.match?(AMOUNT) }
|
|
1266
|
+
captured = statuses(child).grep(SETTLED)
|
|
1267
|
+
next if paid.nil? || captured.empty?
|
|
1268
|
+
|
|
1269
|
+
# Payments of several kinds (a deposit and a fee) answer for
|
|
1270
|
+
# different amounts; the rule compares one kind with the amount it
|
|
1271
|
+
# pays, and is not drafted when nothing says which kind that is.
|
|
1272
|
+
kind = kind_filter(model, amount, child) or next
|
|
1273
|
+
MoneyRule.new(model:, amount:, settled:, assoc:, child:, paid:, captured:, kind:)
|
|
1274
|
+
end
|
|
1275
|
+
end
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
def money_invariants
|
|
1279
|
+
money_rules.map do |r|
|
|
1280
|
+
# A payment of one kind (a deposit) may be taken at booking, before the
|
|
1281
|
+
# record reaches a settled status, and a desk booking may take none:
|
|
1282
|
+
# for it only "never more than the amount" holds in every status.
|
|
1283
|
+
rule, check =
|
|
1284
|
+
if r.kind.empty?
|
|
1285
|
+
["a #{r.settled.join("/")} #{r.model.model_name.human.downcase} took its #{r.amount} through #{r.assoc.name} #{r.captured.join("/")}; any other status took nothing.",
|
|
1286
|
+
"taken == (#{r.settled.inspect}.include?(record.status) ? record.#{r.amount} : 0)"]
|
|
1287
|
+
else
|
|
1288
|
+
["no #{r.model.model_name.human.downcase} took more than its #{r.amount} through #{r.assoc.name} #{r.captured.join("/")}.",
|
|
1289
|
+
"taken <= record.#{r.amount}"]
|
|
1290
|
+
end
|
|
1291
|
+
<<~RUBY.chomp
|
|
1292
|
+
# drafted, check it is a real rule: #{rule}
|
|
1293
|
+
invariant(:#{r.name}) do
|
|
1294
|
+
bad = #{r.model.name}.all.reject do |record|
|
|
1295
|
+
taken = #{r.child.name}.where(#{r.assoc.foreign_key}: record.id, status: #{r.captured.inspect}#{r.kind}).sum(:#{r.paid})
|
|
1296
|
+
#{check}
|
|
1297
|
+
end
|
|
1298
|
+
bad.empty? || "#{r.model.model_name.plural} \#{bad.map(&:id)}: #{r.amount} and the #{r.assoc.name} captured for them differ"
|
|
1299
|
+
end
|
|
1300
|
+
RUBY
|
|
1301
|
+
end
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
# Factories make each record alone: an open invoice and a succeeded
|
|
1305
|
+
# payment for it break the money rule before any action runs. After the
|
|
1306
|
+
# payments are made, each record with a captured one is marked settled at
|
|
1307
|
+
# the amount captured, so the hunt starts from a world the rule allows.
|
|
1308
|
+
def settle_lines(child_var, child_model, made_vars)
|
|
1309
|
+
money_rules.filter_map do |r|
|
|
1310
|
+
next unless r.child == child_model && r.kind.empty? && r.owner
|
|
1311
|
+
|
|
1312
|
+
parent_var = r.owner.to_s.pluralize
|
|
1313
|
+
next unless made_vars.include?(parent_var)
|
|
1314
|
+
|
|
1315
|
+
"#{child_var}.select { #{r.captured.inspect}.include?(it.status) }.group_by(&:#{r.owner}).each { |record, taken| record.update_columns(status: #{r.settled.first.inspect}, #{r.amount}: taken.sum(&:#{r.paid})) } # keeps #{r.name}"
|
|
1316
|
+
end
|
|
1317
|
+
end
|
|
1318
|
+
|
|
1319
|
+
KIND_COLUMN = /\A(kind|purpose|category|payment_type|charge_type)\z/
|
|
1320
|
+
|
|
1321
|
+
# "" when the payments have one kind; ", kind: record.kind" when both
|
|
1322
|
+
# sides name the kind; ", kind: \"deposit\"" when the amount column names
|
|
1323
|
+
# one (deposit_cents); nil when nothing says which payments count.
|
|
1324
|
+
def kind_filter(model, amount, child)
|
|
1325
|
+
col = child.column_names.find { it.match?(KIND_COLUMN) } or return ""
|
|
1326
|
+
return ", #{col}: record.#{col}" if model.column_names.include?(col)
|
|
1327
|
+
|
|
1328
|
+
values = child.validators_on(col.to_sym).grep(ActiveModel::Validations::InclusionValidator).first&.options&.[](:in)
|
|
1329
|
+
values ||= %W[#{col.upcase}S KINDS].filter_map { child.const_get(it, false) if child.const_defined?(it, false) }.first
|
|
1330
|
+
stem = amount.delete_suffix("_cents")
|
|
1331
|
+
found = Array(values).map(&:to_s).find { stem != "amount" && (stem.include?(it) || it.include?(stem)) }
|
|
1332
|
+
found && ", #{col}: #{found.inspect}"
|
|
1333
|
+
end
|
|
1334
|
+
# Order kept: a waiting status (waitlisted, queued, pending) that some code
|
|
1335
|
+
# reads in timestamp order. Rows alike in all but when they entered it must
|
|
1336
|
+
# leave it in that order.
|
|
1337
|
+
def order_invariants
|
|
1338
|
+
code = Rails.root.glob("app/**/*.rb").map { File.read(it) }.join("\n")
|
|
1339
|
+
models.flat_map do |model|
|
|
1340
|
+
all = statuses(model)
|
|
1341
|
+
all.grep(/wait|queue|pend/).filter_map do |from|
|
|
1342
|
+
plural = model.model_name.plural
|
|
1343
|
+
ordered = code.lines.find do |line|
|
|
1344
|
+
line.match?(/\.order\(:?\w*_at\b|order\(created_at|order\(:created_at/) &&
|
|
1345
|
+
(line.match?(/scope :#{from}\b/) || line.match?(/\b#{plural}\.#{from}\b/) || line.include?("status: \"#{from}\""))
|
|
1346
|
+
end or next
|
|
1347
|
+
to = all - [from] - all.grep(/cancel|expire|withdr|delet|reject|fail|declin|refund/)
|
|
1348
|
+
next if to.empty?
|
|
1349
|
+
|
|
1350
|
+
call = code[/(\w+)\.#{plural}\.#{from}\b/, 1]
|
|
1351
|
+
group = ["#{call}_id"].find { model.column_names.include?(it) }
|
|
1352
|
+
name = "#{plural}_leave_#{from}_in_entry_order"
|
|
1353
|
+
<<~RUBY.chomp
|
|
1354
|
+
# drafted, check it is a real rule: read in timestamp order at
|
|
1355
|
+
# #{ordered.strip[0, 100]}
|
|
1356
|
+
# Rows alike in every column but ids and times leave #{from} in the order they entered it.
|
|
1357
|
+
invariant(:#{name}) do
|
|
1358
|
+
Sequences.order_kept(#{model.name}, from: #{from.inspect}, to: #{to.inspect}#{", group: :#{group}" if group})
|
|
1359
|
+
end
|
|
1360
|
+
RUBY
|
|
1361
|
+
end
|
|
1362
|
+
end
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
# One child each: a `has_one` the database does not hold to one row (no
|
|
1366
|
+
# unique index on its key) is held by the code alone, and two requests at
|
|
1367
|
+
# once can leave two, of which the app then reads either.
|
|
1368
|
+
def one_child_invariants
|
|
1369
|
+
models.flat_map do |model|
|
|
1370
|
+
model.reflect_on_all_associations(:has_one).filter_map do |assoc|
|
|
1371
|
+
next if assoc.through_reflection? || assoc.options[:as] || (assoc.scope && assoc.scope.arity != 0)
|
|
1372
|
+
|
|
1373
|
+
child = assoc.klass rescue next
|
|
1374
|
+
fk = assoc.foreign_key.to_s
|
|
1375
|
+
next unless child.table_exists? && child.column_names.include?(fk)
|
|
1376
|
+
next if child.connection.indexes(child.table_name).any? { it.unique && it.columns.is_a?(Array) && it.columns.first == fk && it.columns.size == 1 && it.where.nil? }
|
|
1377
|
+
|
|
1378
|
+
rows = assoc.scope ? "#{model.name}.reflect_on_association(:#{assoc.name}).then { it.klass.instance_exec(&it.scope) }" : child.name
|
|
1379
|
+
<<~RUBY.chomp
|
|
1380
|
+
# drafted, check it is a real rule: #{model.name} has_one :#{assoc.name}, and no unique index holds it to one row.
|
|
1381
|
+
invariant(:#{model.model_name.singular}_has_one_#{assoc.name}) do
|
|
1382
|
+
bad = #{rows}.where.not(#{fk}: nil).group(:#{fk}).having("COUNT(*) > 1").count.keys
|
|
1383
|
+
bad.empty? || "#{model.model_name.plural} \#{bad}: more than one #{assoc.name}"
|
|
1384
|
+
end
|
|
1385
|
+
RUBY
|
|
1386
|
+
end
|
|
1387
|
+
end
|
|
1388
|
+
end
|
|
1389
|
+
|
|
1390
|
+
# Bounds a column name states: what is left never goes below zero, a
|
|
1391
|
+
# count never passes its max, and a percentage stays within 0..100.
|
|
1392
|
+
def bound_invariants
|
|
1393
|
+
models.flat_map do |model|
|
|
1394
|
+
cols = model.columns.select { %i[integer decimal float].include?(it.type) }.map(&:name)
|
|
1395
|
+
plural = model.model_name.plural
|
|
1396
|
+
rules = cols.grep(/(\A|_)(left|remaining)\z/).map do |col|
|
|
1397
|
+
["#{plural}_#{col}_not_negative", "#{col} is never below zero", "#{model.name}.where(\"#{col} < 0\")"]
|
|
1398
|
+
end
|
|
1399
|
+
rules += cols.grep(/\Amax_(\w+)\z/).filter_map do |max|
|
|
1400
|
+
stem = max.delete_prefix("max_")
|
|
1401
|
+
count = cols.find { it == "#{stem}_count" } or next
|
|
1402
|
+
["#{plural}_#{count}_within_#{max}", "#{count} never passes #{max}", "#{model.name}.where(\"#{max} IS NOT NULL AND #{count} > #{max}\")"]
|
|
1403
|
+
end
|
|
1404
|
+
rules += cols.grep(/percent/).map do |col|
|
|
1405
|
+
["#{plural}_#{col}_within_0_100", "#{col} stays within 0..100", "#{model.name}.where(\"#{col} < 0 OR #{col} > 100\")"]
|
|
1406
|
+
end
|
|
1407
|
+
rules.map do |name, says, query|
|
|
1408
|
+
<<~RUBY.chomp
|
|
1409
|
+
# drafted, check it is a real rule: #{model.name}##{says}.
|
|
1410
|
+
invariant(:#{name}) do
|
|
1411
|
+
bad = #{query}.pluck(:id)
|
|
1412
|
+
bad.empty? || "#{plural} \#{bad} break the rule: #{says}"
|
|
1413
|
+
end
|
|
1414
|
+
RUBY
|
|
1415
|
+
end
|
|
1416
|
+
end
|
|
1417
|
+
end
|
|
1418
|
+
|
|
1419
|
+
def drafted_invariants = money_invariants + order_invariants + one_child_invariants + bound_invariants
|
|
1420
|
+
|
|
1421
|
+
# The drafted rules are live; the TODOs ask for the rest. A run with no
|
|
1422
|
+
# invariants still checks errors, 5xx, zones, offsets and doors.
|
|
1423
|
+
def invariants_file
|
|
1424
|
+
stateful = models.select { it.column_names.include?("status") }
|
|
1425
|
+
<<~RUBY
|
|
1426
|
+
# Rules that hold after every step, in every area. Each block returns true
|
|
1427
|
+
# (or nil) when the rule holds, or a message naming the rows that break it.
|
|
1428
|
+
# Keep `\#{...}` inside a plain double-quoted string; a block is Ruby, not a heredoc.
|
|
1429
|
+
Sequences.everywhere do
|
|
1430
|
+
#{drafted_invariants.join("\n\n").gsub(/^(?=.)/, " ")}
|
|
1431
|
+
|
|
1432
|
+
# invariant(:copy_on_loan_has_one_active_loan) do
|
|
1433
|
+
# bad = Copy.where(status: "on_loan").reject { Loan.active.where(copy_id: it.id).count == 1 }
|
|
1434
|
+
# bad.empty? || "copies \#{bad.map(&:id)} on loan without exactly one active loan"
|
|
1435
|
+
# end
|
|
1436
|
+
#{stateful.map { " # TODO invariant over #{it.name}.status: what must hold for each value?" }.join("\n")}
|
|
1437
|
+
end
|
|
1438
|
+
RUBY
|
|
1439
|
+
end
|
|
1440
|
+
|
|
1441
|
+
# An invariants.rb that exists keeps the agent's rules; a drafted rule it
|
|
1442
|
+
# does not name yet goes in before its last `end`.
|
|
1443
|
+
def add_drafted(path)
|
|
1444
|
+
text = File.read(path)
|
|
1445
|
+
missing = drafted_invariants.reject { text.include?(it[/invariant\(:(\w+)\)/, 1]) }
|
|
1446
|
+
return [] if missing.empty?
|
|
1447
|
+
|
|
1448
|
+
at = text.rindex(/^end\b/) or return []
|
|
1449
|
+
File.write(path, text[0...at] + missing.join("\n\n").gsub(/^(?=.)/, " ") + "\n" + text[at..])
|
|
1450
|
+
missing.map { it[/invariant\(:(\w+)\)/, 1] }
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
def summarize(name, selected, stats, own)
|
|
1454
|
+
reached = selected.select { |d| stats[d.name][:outcomes].keys.any? { !it.include?("refused") } }
|
|
1455
|
+
"#{name}: #{selected.size} actions (#{reached.size} succeeded in the probe), writes #{own.first(6).join(", ")}"
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
def main(argv)
|
|
1459
|
+
Rails.application.eager_load!
|
|
1460
|
+
force = argv.delete("--force")
|
|
1461
|
+
json_path = (i = argv.index("--probe-json")) && argv.slice!(i, 2).last
|
|
1462
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1463
|
+
if argv.delete("--all")
|
|
1464
|
+
stats = probe(drafts)
|
|
1465
|
+
File.write(json_path, JSON.pretty_generate(stats)) if json_path
|
|
1466
|
+
dir = Pathname(ENV.fetch("SEQUENCES_OUT", Rails.root.join("spec/sequences").to_s))
|
|
1467
|
+
dir.mkpath
|
|
1468
|
+
invariants = dir.join("invariants.rb")
|
|
1469
|
+
if invariants.exist?
|
|
1470
|
+
added = add_drafted(invariants)
|
|
1471
|
+
$stderr.puts "added drafted invariants to #{invariants}: #{added.join(", ")}" unless added.empty?
|
|
1472
|
+
else
|
|
1473
|
+
File.write(invariants, invariants_file)
|
|
1474
|
+
$stderr.puts "wrote #{invariants} with #{drafted_invariants.size} drafted invariants (check each is a real rule)"
|
|
1475
|
+
end
|
|
1476
|
+
areas(stats).each do |name, selected, own|
|
|
1477
|
+
inserted = made_by_own_actions(selected, stats)
|
|
1478
|
+
source = definition_source(name, selected, stats, refusals: app_refusals(stats, selected), failures:,
|
|
1479
|
+
setup: setup_lines(inserted, setup_tables(selected, stats)), note: "writes #{own.join(", ")}")
|
|
1480
|
+
path = dir.join("#{name}.rb")
|
|
1481
|
+
if path.exist? && !force
|
|
1482
|
+
$stderr.puts "kept #{path.relative_path_from(Rails.root)} (exists; --force replaces it)"
|
|
1483
|
+
else
|
|
1484
|
+
File.write(path, spec_file(name, source))
|
|
1485
|
+
$stderr.puts "wrote #{path.relative_path_from(Rails.root)} #{summarize(name, selected, stats, own)}"
|
|
1486
|
+
end
|
|
1487
|
+
end
|
|
1488
|
+
errors = stats.select { |_, st| st[:errors].any? }
|
|
1489
|
+
$stderr.puts "probe errors (an app class becomes a refusal; any other fails the run):" unless errors.empty?
|
|
1490
|
+
errors.each { |name, st| $stderr.puts " #{name}: #{st[:errors].map { |k, n| "#{k} #{n}" }.join(", ")}" }
|
|
1491
|
+
untouched = drafts.select { |d| stats[d.name][:runs].zero? }
|
|
1492
|
+
$stderr.puts "never drawn in the probe (a pool was empty): #{untouched.map(&:name).join(", ")}" unless untouched.empty?
|
|
1493
|
+
else
|
|
1494
|
+
patterns = argv.map { Regexp.new(it, Regexp::IGNORECASE) }
|
|
1495
|
+
abort "usage: bundle exec ruby propose.rb --all | AREA [AREA...]" if patterns.empty?
|
|
1496
|
+
selected = drafts.select { |d| patterns.any? { d.name.to_s.match?(it) || d.body.match?(it) } }
|
|
1497
|
+
stats = probe(selected)
|
|
1498
|
+
inserted = made_by_own_actions(selected, stats)
|
|
1499
|
+
name = argv.join("_").gsub(/[^a-z0-9_]+/i, "")
|
|
1500
|
+
puts spec_file(name, definition_source(name, selected, stats, refusals: app_refusals(stats, selected), failures:, setup: setup_lines(inserted, setup_tables(selected, stats))))
|
|
1501
|
+
end
|
|
1502
|
+
$stderr.puts "propose: #{(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(1)} s"
|
|
1503
|
+
end
|
|
1504
|
+
end
|
|
1505
|
+
|
|
1506
|
+
Propose.main(ARGV.dup) if $PROGRAM_NAME == __FILE__
|