active_sanction 1.0.1 → 1.1.1

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.
@@ -6,6 +6,7 @@ require "sorbet-runtime"
6
6
  require "time"
7
7
  require "active_sanction/error"
8
8
  require "active_sanction/index"
9
+ require "active_sanction/instrumentation"
9
10
  require "active_sanction/match_result"
10
11
  require "active_sanction/query"
11
12
  require "active_sanction/scorer"
@@ -131,6 +132,14 @@ module ActiveSanction
131
132
  sig { returns(Symbol) }
132
133
  attr_reader :backend
133
134
 
135
+ # Where the `:screen` event goes, or nil for nothing listening. Read once
136
+ # at construction and frozen with everything else here, which is the rule
137
+ # the class comment states for the whole query path: a subscriber swapped
138
+ # halfway through a batch cannot make half of it instrumented. See
139
+ # Instrumentation.
140
+ sig { returns(T.untyped) }
141
+ attr_reader :instrumenter
142
+
134
143
  class << self
135
144
  extend T::Sig
136
145
 
@@ -152,14 +161,29 @@ module ActiveSanction
152
161
  # covers all three, and both report the name clear.
153
162
  sig do
154
163
  params(store: T.untyped, sources: T.untyped, weights: T.untyped, candidate_limit: T.untyped,
155
- backend: T.untyped).returns(Matcher)
164
+ backend: T.untyped, instrumenter: T.untyped).returns(Matcher)
156
165
  end
157
166
  def build(store = nil, sources: nil, weights: nil, candidate_limit: nil,
158
- backend: MatchResult::DEFAULT_BACKEND)
167
+ backend: MatchResult::DEFAULT_BACKEND, instrumenter: nil)
159
168
  store ||= ActiveSanction.config.storage
169
+ listening = instrumenter.nil? ? ActiveSanction.config.instrumenter : instrumenter
170
+ built = Instrumentation.instrument(listening, :"index.build", { store: store.class.name }) do |event|
171
+ index_over(store, sources, event)
172
+ end
173
+ new(index: built.fetch(:index), snapshots: built.fetch(:checksums), verified: built.fetch(:attested),
174
+ weights: weights, candidate_limit: candidate_limit, backend: backend, instrumenter: listening)
175
+ end
176
+
177
+ private
178
+
179
+ # Every list this matcher will hold, read one at a time and released
180
+ # before the next is opened, and what it cost.
181
+ sig { params(store: T.untyped, sources: T.untyped, event: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
182
+ def index_over(store, sources, event)
160
183
  builder = Index::Builder.new
161
184
  checksums = T.let({}, T::Hash[Symbol, String])
162
185
  attested = T.let([], T::Array[Symbol])
186
+ entities = 0
163
187
  requested(store, sources).each do |key|
164
188
  snapshot = store.fetch_snapshot(key)
165
189
  checksums[key] = snapshot.checksum
@@ -168,12 +192,27 @@ module ActiveSanction
168
192
  # different list.
169
193
  attested << key if snapshot.trusted?
170
194
  snapshot.entities.each { |entity| builder.add(entity) }
195
+ entities += snapshot.record_count
171
196
  end
172
- new(index: builder.build, snapshots: checksums, verified: attested, weights: weights,
173
- candidate_limit: candidate_limit, backend: backend)
197
+ index = builder.build
198
+ measure(event, index, checksums, entities)
199
+ { index: index, checksums: checksums, attested: attested }
174
200
  end
175
201
 
176
- private
202
+ # What a host watches at boot and after every sync: how long an index
203
+ # took to build, how much of it there is, and roughly what it weighs.
204
+ # `bytes` is an estimate and says so -- see Index#profile, which is
205
+ # where the assumptions behind the number are written down, and where
206
+ # the entity count deliberately does not come from.
207
+ sig do
208
+ params(event: T.untyped, index: Index, checksums: T::Hash[Symbol, String], entities: Integer).void
209
+ end
210
+ def measure(event, index, checksums, entities)
211
+ event[:sources] = checksums.keys
212
+ event[:snapshots] = checksums
213
+ event[:entities] = entities
214
+ index.profile.each { |name, value| event[name] = value }
215
+ end
177
216
 
178
217
  # The lists to index, in a deterministic order, or the exception that
179
218
  # says why there are none.
@@ -204,10 +243,10 @@ module ActiveSanction
204
243
  # one name against several list versions, or a spec.
205
244
  sig do
206
245
  params(index: Index, snapshots: T.untyped, weights: T.untyped, candidate_limit: T.untyped,
207
- backend: T.untyped, verified: T.untyped).void
246
+ backend: T.untyped, verified: T.untyped, instrumenter: T.untyped).void
208
247
  end
209
248
  def initialize(index:, snapshots:, weights: nil, candidate_limit: nil, backend: MatchResult::DEFAULT_BACKEND,
210
- verified: nil)
249
+ verified: nil, instrumenter: nil)
211
250
  @index = index
212
251
  @snapshots = T.let(snapshots!(snapshots), T::Hash[Symbol, String])
213
252
  @verified = T.let(verified!(verified), T::Array[Symbol])
@@ -216,6 +255,7 @@ module ActiveSanction
216
255
  @weights = T.let(Scorer::Weights.build(weights), Scorer::Weights)
217
256
  @candidate_limit = T.let(candidate_limit!(candidate_limit), Integer)
218
257
  @backend = T.let(backend.to_s.to_sym, Symbol)
258
+ @instrumenter = T.let(instrumenter.nil? ? ActiveSanction.config.instrumenter : instrumenter, T.untyped)
219
259
  freeze
220
260
  end
221
261
 
@@ -303,13 +343,31 @@ module ActiveSanction
303
343
  # weights, one instant, one backend. Only the snapshot checksum varies,
304
344
  # and only because a run may cover several lists.
305
345
  stamp = { query: query, weights: weights, backend: backend, screened_at: screened_at }
306
- scored(query)
307
- .sort_by { |result| [-result.score, result.source.to_s, result.entity.id] }
308
- .first(query.limit)
309
- .map do |result|
310
- MatchResult.from_scorer(result, snapshot_id: snapshots.fetch(result.source),
311
- verified: verified.include?(result.source), **stamp)
312
- end
346
+ Instrumentation.instrument(instrumenter, :screen) do |event|
347
+ results = scored(query, event)
348
+ .sort_by { |result| [-result.score, result.source.to_s, result.entity.id] }
349
+ .first(query.limit)
350
+ .map do |result|
351
+ MatchResult.from_scorer(result, snapshot_id: snapshots.fetch(result.source),
352
+ verified: verified.include?(result.source), **stamp)
353
+ end
354
+ describe(event, query, results)
355
+ results
356
+ end
357
+ end
358
+
359
+ # What one query cost and what it consulted. `snapshots` is the whole
360
+ # checksum map rather than a count, because the question an audit asks of
361
+ # a screening event is which list *versions* answered it -- the same
362
+ # question every MatchResult is stamped with, and the only one a result
363
+ # set of zero cannot answer for itself.
364
+ sig { params(event: T.untyped, query: Query, results: T::Array[MatchResult]).void }
365
+ def describe(event, query, results)
366
+ event[:results] = results.size
367
+ event[:threshold] = query.threshold
368
+ event[:limit] = query.limit
369
+ event[:sources] = query.sources || sources
370
+ event[:snapshots] = snapshots
313
371
  end
314
372
 
315
373
  # Every entity the index retrieved, scored once.
@@ -321,10 +379,11 @@ module ActiveSanction
321
379
  # retrieves the same record under several spellings, and rescoring one
322
380
  # that has already failed the threshold is the most expensive way to
323
381
  # arrive at the same no.
324
- sig { params(query: Query).returns(T::Array[Scorer::Result]) }
325
- def scored(query)
382
+ sig { params(query: Query, event: T.untyped).returns(T::Array[Scorer::Result]) }
383
+ def scored(query, event)
326
384
  seen = T.let({}, T::Hash[[Symbol, String], T.nilable(Scorer::Result)])
327
- index.candidates(query.form, limit: candidate_limit, sources: query.sources).each do |candidate|
385
+ retrieved = index.candidates(query.form, limit: candidate_limit, sources: query.sources)
386
+ retrieved.each do |candidate|
328
387
  # Keyed by list as well as id, because the same person really is two
329
388
  # records when two governments list them, and both belong in a report.
330
389
  key = [candidate.source, candidate.entity.id]
@@ -332,6 +391,12 @@ module ActiveSanction
332
391
 
333
392
  seen[key] = Scorer.call(query.subject, candidate, weights: weights, threshold: query.threshold)
334
393
  end
394
+ # Names retrieved, and the entities they came down to. The two differ by
395
+ # however many aliases of one record the query looked like, and a
396
+ # candidate cap is a cap on the first rather than the second -- which is
397
+ # the number to watch when tuning it.
398
+ event[:candidates] = retrieved.size
399
+ event[:scored] = seen.size
335
400
  seen.values.compact
336
401
  end
337
402
 
@@ -8,6 +8,7 @@ require "active_sanction/error"
8
8
  require "active_sanction/entity"
9
9
  require "active_sanction/snapshot"
10
10
  require "active_sanction/fetcher"
11
+ require "active_sanction/instrumentation"
11
12
  require "active_sanction/parsers"
12
13
  require "active_sanction/payload_cache"
13
14
  require "active_sanction/sources"
@@ -82,17 +83,25 @@ module ActiveSanction
82
83
  sig { returns(T.untyped) }
83
84
  attr_reader :logger
84
85
 
86
+ # Where this adapter's `:parse` event goes, or nil for nothing
87
+ # listening. See Instrumentation.
88
+ sig { returns(T.untyped) }
89
+ attr_reader :instrumenter
90
+
85
91
  # `cache: nil` turns off payload caching, which costs one thing worth
86
92
  # knowing: a multi-file source can no longer answer a sync where some of
87
93
  # its files changed and others came back 304, so the unchanged ones are
88
94
  # downloaded again in full.
89
95
  sig do
90
- params(fetcher: Fetcher, cache: T.nilable(PayloadCache), logger: T.untyped).void
96
+ params(fetcher: Fetcher, cache: T.nilable(PayloadCache), logger: T.untyped,
97
+ instrumenter: T.untyped).void
91
98
  end
92
- def initialize(fetcher: Fetcher.new, cache: PayloadCache.new, logger: ActiveSanction.config.logger)
99
+ def initialize(fetcher: Fetcher.new, cache: PayloadCache.new, logger: ActiveSanction.config.logger,
100
+ instrumenter: ActiveSanction.config.instrumenter)
93
101
  @fetcher = T.let(fetcher, Fetcher)
94
102
  @cache = T.let(cache, T.nilable(PayloadCache))
95
103
  @logger = T.let(logger, T.untyped)
104
+ @instrumenter = T.let(instrumenter, T.untyped)
96
105
  @results = T.let({}, T::Hash[Symbol, Fetcher::Result])
97
106
  end
98
107
 
@@ -137,6 +146,20 @@ module ActiveSanction
137
146
  "#{self.class} must implement #parse(raw) and return an Array of ActiveSanction::Entity"
138
147
  end
139
148
 
149
+ # What the last #parse could not read: a Parsers::Warning per row that
150
+ # was skipped or could not be mapped, kept rather than raised. Every
151
+ # shipped adapter overrides this with the parser's own warnings plus
152
+ # whatever it noticed itself, which is what the adapter rules require of
153
+ # a new one.
154
+ #
155
+ # Empty here rather than abstract, because an adapter that genuinely
156
+ # cannot fail to read a row should not have to say so, and because the
157
+ # `:parse` event counts these for every source and a count that is
158
+ # sometimes a NoMethodError is not a metric. See Doctor, which reads the
159
+ # warnings themselves rather than the count.
160
+ sig { returns(T::Array[Parsers::Warning]) }
161
+ def warnings = []
162
+
140
163
  # Fetches, parses, and checksums -- or returns nil when the publisher
141
164
  # says nothing has changed, which is the outcome to expect on most runs
142
165
  # and the reason conditional GET exists.
@@ -157,8 +180,15 @@ module ActiveSanction
157
180
  # or, for a source that declares a single file, as the bytes themselves.
158
181
  sig { params(payloads: T.untyped, files: T.untyped).returns(Snapshot) }
159
182
  def snapshot(payloads = nil, **files)
160
- Snapshot.new(source: key, entities: parse(parse_argument(payloads || files)),
161
- fetched_at: Time.now.utc, source_version: source_version)
183
+ raw = parse_argument(payloads || files)
184
+ entities = Instrumentation.instrument(instrumenter, :parse,
185
+ { source: declared_key, bytes: byte_count(raw) }) do |event|
186
+ parsed = parse(raw)
187
+ event[:records] = parsed.size
188
+ event[:warnings] = warnings.size
189
+ parsed
190
+ end
191
+ Snapshot.new(source: key, entities: entities, fetched_at: Time.now.utc, source_version: source_version)
162
192
  rescue ActiveSanction::Error => e
163
193
  raise e.in_source(declared_key)
164
194
  end
@@ -250,7 +280,19 @@ module ActiveSanction
250
280
 
251
281
  sig { params(name: Symbol, address: String, force: T::Boolean).returns(Fetcher::Result) }
252
282
  def fetch_file(name, address, force)
253
- fetcher.fetch(address, key: file_key(name), force: force).success!
283
+ fetcher.fetch(address, key: file_key(name), force: force, source: declared_key).success!
284
+ end
285
+
286
+ # How many bytes #parse was handed, across every file of a multi-file
287
+ # source. Taken before the parse rather than after, so the `:parse`
288
+ # event still says how large the document was when the parse is what
289
+ # raised.
290
+ sig { params(raw: T.untyped).returns(Integer) }
291
+ def byte_count(raw)
292
+ return raw.bytesize if raw.is_a?(String)
293
+ return raw.to_h.each_value.sum { |payload| payload.to_s.bytesize } if raw.respond_to?(:to_h)
294
+
295
+ raw.to_s.bytesize
254
296
  end
255
297
 
256
298
  sig { params(name: Symbol).returns(T.untyped) }
@@ -283,7 +325,7 @@ module ActiveSanction
283
325
  sig { params(name: Symbol).returns(Fetcher::Result) }
284
326
  def refetch(name)
285
327
  logger&.info("[active_sanction] #{key} #{name} unchanged but not cached; fetching in full")
286
- result = fetcher.fetch(url(name), key: file_key(name), force: true).success!
328
+ result = fetcher.fetch(url(name), key: file_key(name), force: true, source: declared_key).success!
287
329
  return result if result.changed?
288
330
 
289
331
  raise MissingPayload,
@@ -5,6 +5,7 @@ require "sorbet-runtime"
5
5
 
6
6
  require "uri"
7
7
  require "active_sanction/error"
8
+ require "active_sanction/instrumentation"
8
9
  require "active_sanction/sources"
9
10
  require "active_sanction/storage"
10
11
  require "active_sanction/sync/result"
@@ -149,6 +150,17 @@ module ActiveSanction
149
150
  sig { returns(T.untyped) }
150
151
  attr_reader :logger
151
152
 
153
+ # Where the `:sync` and `:store` events go, or nil for nothing listening.
154
+ #
155
+ # The `:fetch` and `:parse` events of the sources this run covers do not
156
+ # come from here: an adapter is constructed by the run and reads the
157
+ # configuration, exactly as it does for its logger and its User-Agent. So
158
+ # a host that instruments through `ActiveSanction.configure` sees all six
159
+ # events, and one that hands a run its own instrumenter sees the two this
160
+ # class emits. See Instrumentation.
161
+ sig { returns(T.untyped) }
162
+ attr_reader :instrumenter
163
+
152
164
  sig { params(options: T.untyped, block: T.untyped).returns(Report) }
153
165
  def self.call(**options, &block) = T.unsafe(self).new(**options).call(&block)
154
166
 
@@ -157,9 +169,10 @@ module ActiveSanction
157
169
  # raises here, before the first list is downloaded, rather than after.
158
170
  sig do
159
171
  params(sources: T.untyped, store: T.untyped, force: T::Boolean, concurrency: T.untyped,
160
- logger: T.untyped).void
172
+ logger: T.untyped, instrumenter: T.untyped).void
161
173
  end
162
- def initialize(sources: nil, store: nil, force: false, concurrency: nil, logger: ActiveSanction.config.logger)
174
+ def initialize(sources: nil, store: nil, force: false, concurrency: nil, logger: ActiveSanction.config.logger,
175
+ instrumenter: ActiveSanction.config.instrumenter)
163
176
  @sources = T.let(resolve(sources), T::Array[T.untyped])
164
177
  @keys = T.let(@sources.map { |source| Sources::Definition.key!(source.key) }, T::Array[Symbol])
165
178
  @store = T.let(store || ActiveSanction.storage, T.untyped)
@@ -168,6 +181,7 @@ module ActiveSanction
168
181
  Configuration.sync_concurrency!(concurrency || ActiveSanction.config.sync_concurrency), Integer
169
182
  )
170
183
  @logger = T.let(logger, T.untyped)
184
+ @instrumenter = T.let(instrumenter, T.untyped)
171
185
  @lock = T.let(Mutex.new, Mutex)
172
186
  # The settings this run was started under, so a worker thread reads them
173
187
  # rather than the default client's. A configuration is fiber-local and a
@@ -195,9 +209,17 @@ module ActiveSanction
195
209
  started_at = Time.now.utc
196
210
  began = monotonic
197
211
  log_start
198
- work = keys.each_with_index.map { |key, at| [at, key, sources.fetch(at)] }
199
- results = run(work, &block).sort_by(&:first).map(&:last)
200
- report = Report.new(results: results, started_at: started_at, duration: elapsed(began))
212
+ # The run's own duration is the Report's, taken from the same clock
213
+ # reading, so an event and the report a caller is holding never disagree
214
+ # about how long a run took.
215
+ report = Instrumentation.instrument(instrumenter, :sync,
216
+ { sources: keys, forced: force, concurrency: concurrency }) do |event|
217
+ work = keys.each_with_index.map { |key, at| [at, key, sources.fetch(at)] }
218
+ results = run(work, &block).sort_by(&:first).map(&:last)
219
+ finished = Report.new(results: results, started_at: started_at, duration: elapsed(began))
220
+ summarize(event, finished)
221
+ finished
222
+ end
201
223
  log_finish(report)
202
224
  report
203
225
  end
@@ -285,13 +307,39 @@ module ActiveSanction
285
307
  snapshot = adapter.sync(force: force || previous.nil?)
286
308
  return complete(key, :unchanged, previous, started) if unchanged?(previous, snapshot)
287
309
 
288
- store.write_snapshot(snapshot)
310
+ write(key, snapshot)
289
311
  complete(key, :updated, Storage::Meta.from_snapshot(snapshot), started)
290
312
  rescue StandardError => e
291
313
  complete(key, :failed, previous, started, stamp(key, e))
292
314
  end
293
315
  end
294
316
 
317
+ # Writes one list, and says what it cost. Separate from the rest of
318
+ # #sync_source so the `:store` event times the write and nothing else --
319
+ # a store that takes eleven seconds to persist 19,321 entities is a
320
+ # different operational problem from a publisher that takes eleven seconds
321
+ # to serve them, and a timing that covered both could not tell a host
322
+ # which one it had.
323
+ sig { params(key: Symbol, snapshot: T.untyped).void }
324
+ def write(key, snapshot)
325
+ fields = { source: key, snapshot_id: snapshot.checksum, entities: snapshot.record_count,
326
+ store: store.class.name }
327
+ Instrumentation.instrument(instrumenter, :store, fields) { store.write_snapshot(snapshot) }
328
+ end
329
+
330
+ # What a run did, as counts rather than as the Report itself: a subscriber
331
+ # forwarding an event to a metrics backend wants numbers, and one that
332
+ # wants the whole report already has it as the return value of the call
333
+ # that emitted this.
334
+ sig { params(event: T.untyped, report: Report).void }
335
+ def summarize(event, report)
336
+ event[:outcomes] = report.results.to_h { |result| [result.source, result.status] }
337
+ event[:updated] = report.updated.size
338
+ event[:unchanged] = report.unchanged.size
339
+ event[:failed] = report.failed.size
340
+ event[:records] = report.record_count
341
+ end
342
+
295
343
  # A failure captured for a source names that source, even when it was
296
344
  # raised somewhere that could not know -- a store that will not open, an
297
345
  # adapter constructor. Only ever fills a blank; see Error#in_source.
@@ -0,0 +1,234 @@
1
+ # typed: ignore
2
+ # frozen_string_literal: true
3
+
4
+ # The contract every source adapter must satisfy, written once.
5
+ #
6
+ # Loaded by `require "active_sanction/testing"` -- see ActiveSanction::Testing,
7
+ # which is where the entry point and the fixture root are documented.
8
+ #
9
+ # RSpec.describe ActiveSanction::Sources::CanadaSema do
10
+ # it_behaves_like "a sanction source", fixture: "canada_sema/sema.xml"
11
+ # end
12
+ #
13
+ # A list its publisher splits across several files names each one, under the
14
+ # keys the adapter declared its URLs with:
15
+ #
16
+ # it_behaves_like "a sanction source",
17
+ # fixture: { sdn: "ofac_sdn/SDN.CSV", alt: "ofac_sdn/ALT.CSV", add: "ofac_sdn/ADD.CSV" }
18
+ #
19
+ # Paths are relative to `spec/fixtures` -- or to whatever
20
+ # `ActiveSanction::Testing.fixture_root` names, and an absolute path is taken
21
+ # as it stands. The bytes reach #parse exactly as they were committed --
22
+ # undecoded, so an adapter reading the Windows-1252 OFAC serves is held to
23
+ # doing its own decoding rather than to being handed a String somebody already
24
+ # fixed up.
25
+ #
26
+ # ### What this is for
27
+ #
28
+ # Everything downstream of an adapter -- storage, the diff between two syncs,
29
+ # the index, the matcher, the report an examiner reads -- is written against
30
+ # Entity and never against a list. That only holds if every adapter really
31
+ # does produce the same shape, and "the same shape" is otherwise a paragraph
32
+ # in a design document that each new adapter re-interprets. Here it is
33
+ # executable, so adding a jurisdiction is a checklist rather than an open
34
+ # research question.
35
+ #
36
+ # ### What it does not do
37
+ #
38
+ # It does not check that the adapter read its list *correctly*. Nothing here
39
+ # knows that OFAC writes `-0-` for null, that the UN means two different
40
+ # things by QUALITY, or which of the fixture's records is a vessel. Only a
41
+ # spec that knows what is in the fixture can check that, so every adapter
42
+ # still writes its own; this group is the floor, not the ceiling.
43
+ #
44
+ # ### Options
45
+ #
46
+ # fixture: required. A path under the fixture root, or a Hash of one path
47
+ # per declared URL. Real published records, not invented ones --
48
+ # a conformance run against a fixture somebody wrote to pass it
49
+ # proves nothing about the list.
50
+ #
51
+ # remarks: pass `remarks: false` for a list that publishes no free text of
52
+ # its own at all, so the check that the publisher's own words
53
+ # survive into `remarks` is skipped. Every list at launch does
54
+ # publish some, which is why the default is to require it: a
55
+ # remark quietly dropped is how a place of birth or a passport
56
+ # number stops reaching #19.
57
+ RSpec.shared_examples "a sanction source" do |options = {}|
58
+ publishes_remarks = options.fetch(:remarks, true)
59
+
60
+ # Read through a `let` rather than closed over directly, so that a group
61
+ # that forgot to name a fixture says so when an example asks for one rather
62
+ # than while the suite is still loading.
63
+ let(:fixture) do
64
+ options.fetch(:fixture) do
65
+ raise ArgumentError, %(pass the fixture to parse: it_behaves_like "a sanction source", fixture: "list.xml")
66
+ end
67
+ end
68
+
69
+ let(:source) { described_class.new }
70
+ let(:payload) { fixture_payload(fixture) { |bytes| bytes } }
71
+ let(:records) { source.parse(payload) }
72
+
73
+ # Fixture bytes in the shape this adapter's #parse takes: the bytes
74
+ # themselves for a source declaring one file, a Hash keyed by declaration
75
+ # name for one declaring several. The same rule Base applies before calling
76
+ # #parse, so an adapter is exercised here exactly as `sync` exercises it.
77
+ def fixture_payload(paths)
78
+ bytes = named_fixtures(paths).to_h { |name, path| [name, yield(File.binread(fixture_path(path)))] }
79
+ described_class.multi_url? ? bytes : bytes.values.first
80
+ end
81
+
82
+ # One fixture path per declared file. A source declaring one file may name it
83
+ # as a bare path, which is what reading a fixture off disk looks like.
84
+ def named_fixtures(paths)
85
+ files = paths.is_a?(Hash) ? paths : { described_class.urls.keys.first => paths }
86
+ missing = described_class.urls.keys - files.keys
87
+ raise ArgumentError, "no fixture given for the #{missing.join(", ")} file(s) this source declares" if missing.any?
88
+
89
+ files
90
+ end
91
+
92
+ # Resolved through Testing rather than relative to this file, which is what
93
+ # lets the group work for a project whose fixtures are its own. See
94
+ # ActiveSanction::Testing.fixture_root.
95
+ def fixture_path(path) = ActiveSanction::Testing.fixture_path(path)
96
+
97
+ # Every date any entity carries, wherever the model puts them.
98
+ def entity_dates(entities)
99
+ entities.flat_map do |entity|
100
+ [entity.listed_on, *entity.dates_of_birth,
101
+ *entity.identifiers.flat_map { |identifier| [identifier.issued_on, identifier.expires_on] }]
102
+ end.compact
103
+ end
104
+
105
+ # The serialized records half the bytes produce, or the marker that reading
106
+ # them raised -- which is the other acceptable answer, and never equal to a
107
+ # complete parse.
108
+ def parsed_truncated(paths)
109
+ truncated = fixture_payload(paths) { |bytes| bytes[0, bytes.bytesize / 2] }
110
+ described_class.new.parse(truncated).map(&:to_h)
111
+ rescue ActiveSanction::Error
112
+ :raised
113
+ end
114
+
115
+ describe "what it declares" do
116
+ it "declares a key, which is the name the list answers to everywhere" do
117
+ expect(described_class.key.to_s).to match(ActiveSanction::Sources::Definition::KEY_PATTERN)
118
+ end
119
+
120
+ it "declares the jurisdiction behind the list" do
121
+ expect(described_class.jurisdiction).to be_a(Symbol)
122
+ end
123
+
124
+ it "declares the authority a compliance report has to print beside a hit" do
125
+ expect(described_class.authority).to be_a(String)
126
+ end
127
+
128
+ it "declares at least one URL to fetch the list from" do
129
+ expect(described_class.urls).not_to be_empty
130
+ end
131
+
132
+ # An adapter whose file is required but which never registers is invisible:
133
+ # `config.sources` cannot name it and `sync` will not run it, and nothing
134
+ # says so out loud.
135
+ it "registers itself, so requiring its file is enough to reach it" do
136
+ expect(ActiveSanction::Sources[described_class.key]).to eq(described_class)
137
+ end
138
+ end
139
+
140
+ describe "what #parse returns" do
141
+ it "builds at least one record from the fixture" do
142
+ expect(records).not_to be_empty
143
+ end
144
+
145
+ it "returns Entities and nothing else" do
146
+ expect(records).to all(be_an(ActiveSanction::Entity))
147
+ end
148
+
149
+ it "stamps every entity with this adapter's own key" do
150
+ expect(records.map(&:source).uniq).to eq([described_class.key])
151
+ end
152
+
153
+ it "gives every entity an id" do
154
+ expect(records.map(&:id)).to all(match(/\S/))
155
+ end
156
+
157
+ # A record with no name cannot be screened against, so it is not a record.
158
+ it "gives every entity at least one name" do
159
+ expect(records.reject { |record| record.names.any? }).to be_empty
160
+ end
161
+
162
+ # Types are the matcher's coarsest filter: a search for a person that can
163
+ # rank a ship is the failure this closes. Entity refuses anything else on
164
+ # construction, so this fails only for something that is not an Entity.
165
+ it "types every entity as one of the four canonical types" do
166
+ expect(records.map(&:type).uniq - ActiveSanction::Entity::TYPES).to be_empty
167
+ end
168
+
169
+ # Two records under one id are one record to storage, and the second
170
+ # silently replaces the first.
171
+ it "gives no two entities the same id" do
172
+ expect(records.map(&:id).tally.select { |_id, count| count > 1 }).to be_empty
173
+ end
174
+ end
175
+
176
+ # #35 diffs two syncs by comparing records under their ids. An id that moves
177
+ # because a Hash iterated differently, or because a counter was involved,
178
+ # reports the whole list as removed and re-added -- and a diff that says
179
+ # everything changed says nothing at all.
180
+ describe "reading the same bytes twice" do
181
+ it "produces the same ids" do
182
+ expect(described_class.new.parse(payload).map(&:id)).to eq(records.map(&:id))
183
+ end
184
+
185
+ it "produces the same records, in the same order" do
186
+ expect(described_class.new.parse(payload).map(&:to_h)).to eq(records.map(&:to_h))
187
+ end
188
+ end
189
+
190
+ describe "the canonical model it produces" do
191
+ # A date left as the publisher's string cannot be compared with one from
192
+ # another list, and PartialDate exists because none of these lists agree on
193
+ # how precise a date of birth is. Entity does not coerce, so an adapter
194
+ # that forgets is caught here rather than in the scorer.
195
+ it "publishes every date as a PartialDate, never as the string it was written as" do
196
+ expect(entity_dates(records)).to all(be_a(ActiveSanction::PartialDate))
197
+ end
198
+
199
+ # What storage (#24) does to every record on the way to disk and back.
200
+ it "round-trips every entity through the serialized form" do
201
+ expect(records.map { |record| ActiveSanction::Entity.from_h(record.to_h) }).to eq(records)
202
+ end
203
+
204
+ if publishes_remarks
205
+ # The publisher's own prose is where the fields the canonical model has
206
+ # no home for still live -- OFAC's dates of birth and passport numbers
207
+ # are nowhere else -- so an adapter that drops it loses data no later
208
+ # issue can get back.
209
+ it "keeps the publisher's own text in remarks" do
210
+ expect(records.filter_map { |record| ActiveSanction::Sources::Remarks.published(record.remarks) })
211
+ .not_to be_empty
212
+ end
213
+ end
214
+ end
215
+
216
+ describe "a payload that is not the list" do
217
+ # No sanctions list has ever been empty, so an empty payload is a failed
218
+ # download, a moved URL or an outage -- never a day on which nobody is
219
+ # sanctioned. Returning [] lets a sync succeed at screening against
220
+ # nothing, which is the most expensive way this library can fail.
221
+ it "refuses an empty payload rather than reporting a list with nobody on it" do
222
+ expect { described_class.new.parse(fixture_payload(fixture) { "" }) }
223
+ .to raise_error(ActiveSanction::Error)
224
+ end
225
+
226
+ # A truncated download may still be worth salvaging -- the XML toolkit
227
+ # keeps the records it read before the break and warns -- so what is
228
+ # required is not that it raises, only that half a list never comes back
229
+ # looking exactly like the whole one.
230
+ it "does not report a truncated document as though it were the whole list" do
231
+ expect(parsed_truncated(fixture)).not_to eq(records.map(&:to_h))
232
+ end
233
+ end
234
+ end