@opentermsarchive/engine 16.0.2 → 16.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.
Files changed (51) hide show
  1. package/config/default.json +13 -0
  2. package/config/test.json +12 -0
  3. package/package.json +1 -1
  4. package/scripts/declarations/validate/index.mocha.js +7 -0
  5. package/scripts/import/index.js +1 -1
  6. package/scripts/import/loadCommits.js +1 -1
  7. package/scripts/rewrite/initializer/index.js +1 -1
  8. package/scripts/rewrite/rewrite-snapshots.js +1 -1
  9. package/scripts/rewrite/rewrite-versions.js +1 -1
  10. package/src/archivist/fetcher/htmlOnlyFetcher.js +8 -0
  11. package/src/archivist/fetcher/index.test.js +12 -0
  12. package/src/archivist/index.js +106 -23
  13. package/src/archivist/index.test.js +659 -9
  14. package/src/archivist/recorder/repositories/git/dataMapper.js +2 -1
  15. package/src/archivist/recorder/repositories/git/index.js +2 -13
  16. package/src/archivist/recorder/repositories/git/index.test.js +23 -1
  17. package/src/archivist/services/index.js +45 -1
  18. package/src/archivist/services/index.test.js +52 -1
  19. package/src/archivist/services/sourceDocument.js +8 -2
  20. package/src/archivist/services/sourceDocument.test.js +37 -0
  21. package/src/archivist/tracking-results/errors.js +5 -0
  22. package/src/archivist/tracking-results/index.js +233 -0
  23. package/src/archivist/tracking-results/index.test.js +406 -0
  24. package/src/archivist/tracking-results/recorder.js +156 -0
  25. package/src/archivist/tracking-results/recorder.test.js +559 -0
  26. package/src/archivist/tracking-results/repository.js +136 -0
  27. package/src/archivist/tracking-results/repository.test.js +763 -0
  28. package/src/archivist/tracking-results/run/dataMapper.js +51 -0
  29. package/src/archivist/tracking-results/run/dataMapper.test.js +168 -0
  30. package/src/archivist/tracking-results/run/index.js +115 -0
  31. package/src/archivist/tracking-results/run/index.test.js +221 -0
  32. package/src/archivist/tracking-results/terms-result/dataMapper.js +200 -0
  33. package/src/archivist/tracking-results/terms-result/dataMapper.test.js +575 -0
  34. package/src/archivist/tracking-results/terms-result/index.js +42 -0
  35. package/src/archivist/tracking-results/terms-result/index.test.js +228 -0
  36. package/src/git/errors.js +1 -0
  37. package/src/{archivist/recorder/repositories/git/git.js → git/index.js} +59 -9
  38. package/src/git/index.test.js +266 -0
  39. package/src/git/pathSegment.js +18 -0
  40. package/src/git/pathSegment.test.js +33 -0
  41. package/src/index.js +5 -4
  42. package/src/reporter/factory.js +2 -2
  43. package/src/reporter/github/index.js +7 -5
  44. package/src/reporter/github/index.test.js +16 -1
  45. package/src/reporter/gitlab/index.js +6 -6
  46. package/src/reporter/gitlab/index.test.js +24 -1
  47. package/src/reporter/index.js +9 -4
  48. package/src/reporter/index.test.js +74 -9
  49. package/src/archivist/recorder/repositories/git/git.test.js +0 -114
  50. /package/src/{archivist/recorder/repositories/git → git}/trailers.js +0 -0
  51. /package/src/{archivist/recorder/repositories/git → git}/trailers.test.js +0 -0
@@ -0,0 +1,575 @@
1
+ import { expect } from 'chai';
2
+
3
+ import {
4
+ EVENT_TYPES,
5
+ TRANSITIONS_BY_EVENT_TYPE,
6
+ deriveFailureType,
7
+ determineEventType,
8
+ formatMessage,
9
+ generateFilePath,
10
+ toDomain,
11
+ toPersistence,
12
+ } from './dataMapper.js';
13
+
14
+ import TermsResult, { STATUSES } from './index.js';
15
+
16
+ const SERVICE_ID = 'Facebook';
17
+ const SERVICE_NAME = 'Facebook';
18
+ const TERMS_TYPE = 'Terms of Service';
19
+
20
+ const SOURCE_DOCUMENTS = [{
21
+ id: 'main',
22
+ fetch: 'https://example.com',
23
+ select: '.content',
24
+ remove: '',
25
+ filter: [],
26
+ executeClientScripts: false,
27
+ mimeType: 'text/html',
28
+ snapshotId: 'def456',
29
+ }];
30
+
31
+ function makeResult({
32
+ status = STATUSES.ok,
33
+ serviceName = SERVICE_NAME,
34
+ sourceDocuments = SOURCE_DOCUMENTS,
35
+ reasons,
36
+ transientError,
37
+ date = '2026-01-10T10:30:00Z',
38
+ } = {}) {
39
+ const event = { date, serviceName, sourceDocuments };
40
+
41
+ if (reasons) {
42
+ event.reasons = reasons;
43
+ }
44
+
45
+ if (transientError) {
46
+ event.transientError = transientError;
47
+ }
48
+
49
+ return new TermsResult({ serviceId: SERVICE_ID, termsType: TERMS_TYPE, status, event });
50
+ }
51
+
52
+ describe('tracking-result/dataMapper', () => {
53
+ describe('#generateFilePath', () => {
54
+ it('joins serviceId and termsType with a forward slash and a .json extension', () => {
55
+ expect(generateFilePath('Facebook', 'Terms of Service')).to.equal('Facebook/Terms of Service.json');
56
+ });
57
+
58
+ it('accepts dots in the middle of components', () => {
59
+ expect(generateFilePath('Facebook v2.0', 'Terms of Service')).to.equal('Facebook v2.0/Terms of Service.json');
60
+ });
61
+
62
+ [ '../etc', 'a/b', 'a\\b', 'with\0null', 'line\nbreak', 'Service "A"', 're:start', '.', '..', '', null, undefined, 123 ].forEach(bad => {
63
+ it(`rejects serviceId ${JSON.stringify(bad)}`, () => {
64
+ expect(() => generateFilePath(bad, 'Terms of Service')).to.throw(/Invalid serviceId/);
65
+ });
66
+
67
+ it(`rejects termsType ${JSON.stringify(bad)}`, () => {
68
+ expect(() => generateFilePath('Facebook', bad)).to.throw(/Invalid termsType/);
69
+ });
70
+ });
71
+ });
72
+
73
+ describe('#deriveFailureType', () => {
74
+ it('returns "fetch" when any reason is prefixed with [fetch]', () => {
75
+ expect(deriveFailureType([ '[extraction] selector miss', '[fetch] HTTP 500' ])).to.equal('fetch');
76
+ });
77
+
78
+ it('returns "extraction" when reasons mix [extraction] and [internal] without [fetch]', () => {
79
+ expect(deriveFailureType([ '[internal] boom', '[extraction] selector miss' ])).to.equal('extraction');
80
+ });
81
+
82
+ it('returns "internal" when only [internal] reasons are present', () => {
83
+ expect(deriveFailureType(['[internal] boom'])).to.equal('internal');
84
+ });
85
+
86
+ it('returns "internal" when no recognised prefix is found', () => {
87
+ expect(deriveFailureType(['something weird'])).to.equal('internal');
88
+ });
89
+
90
+ it('returns "internal" when reasons are empty', () => {
91
+ expect(deriveFailureType([])).to.equal('internal');
92
+ });
93
+ });
94
+
95
+ describe('#determineEventType', () => {
96
+ context('with no previous result', () => {
97
+ it('returns FIRST_TRACKING', () => {
98
+ expect(determineEventType(null, makeResult())).to.equal(EVENT_TYPES.FIRST_TRACKING);
99
+ });
100
+
101
+ context('when the new result is a failure', () => {
102
+ it('returns FIRST_TRACKING_FAILURE', () => {
103
+ expect(determineEventType(null, makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 500'] }))).to.equal(EVENT_TYPES.FIRST_TRACKING_FAILURE);
104
+ });
105
+ });
106
+ });
107
+
108
+ context('when status transitions from ok to failed', () => {
109
+ it('returns TRACKING_FAILURE', () => {
110
+ const prev = makeResult();
111
+ const next = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 500'] });
112
+
113
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.TRACKING_FAILURE);
114
+ });
115
+ });
116
+
117
+ context('when status transitions from failed to ok', () => {
118
+ it('returns TRACKING_RECOVERY', () => {
119
+ const prev = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 500'] });
120
+ const next = makeResult();
121
+
122
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.TRACKING_RECOVERY);
123
+ });
124
+ });
125
+
126
+ context('when same failed status with different reasons', () => {
127
+ it('returns REASONS_CHANGED', () => {
128
+ const prev = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 500'] });
129
+ const next = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 404'] });
130
+
131
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.REASONS_CHANGED);
132
+ });
133
+ });
134
+
135
+ context('when transient error appears', () => {
136
+ it('returns TRANSIENT_ERROR_DETECTED', () => {
137
+ expect(determineEventType(makeResult(), makeResult({ transientError: { reasons: ['[fetch] HTTP 503'] } }))).to.equal(EVENT_TYPES.TRANSIENT_ERROR_DETECTED);
138
+ });
139
+ });
140
+
141
+ context('when transient error disappears', () => {
142
+ it('returns TRANSIENT_ERROR_RESOLVED', () => {
143
+ expect(determineEventType(makeResult({ transientError: { reasons: ['[fetch] HTTP 503'] } }), makeResult())).to.equal(EVENT_TYPES.TRANSIENT_ERROR_RESOLVED);
144
+ });
145
+ });
146
+
147
+ context('when transient error reasons change while both runs have one', () => {
148
+ it('returns null', () => {
149
+ const prev = makeResult({ transientError: { reasons: ['[fetch] HTTP 503'] } });
150
+ const next = makeResult({ transientError: { reasons: ['[fetch] HTTP 504'] } });
151
+
152
+ expect(determineEventType(prev, next)).to.be.null;
153
+ });
154
+ });
155
+
156
+ context('when source documents differ on a non-mimeType field', () => {
157
+ it('returns DECLARATION_UPDATED', () => {
158
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }] });
159
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://b.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }] });
160
+
161
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.DECLARATION_UPDATED);
162
+ });
163
+
164
+ it('is insensitive to source-document key ordering', () => {
165
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }] });
166
+ const next = makeResult({ sourceDocuments: [{ snapshotId: 'abc', mimeType: 'text/html', select: '.x', fetch: 'https://a.example', id: 'main' }] }); // Same keys, different order
167
+
168
+ expect(determineEventType(prev, next)).to.equal(null);
169
+ });
170
+ });
171
+
172
+ context('when only mimeType differs in source documents', () => {
173
+ it('returns MIME_TYPE_UPDATED', () => {
174
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }] });
175
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'application/pdf', snapshotId: 'abc' }] });
176
+
177
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.MIME_TYPE_UPDATED);
178
+ });
179
+ });
180
+
181
+ context('when only snapshotId differs in source documents', () => {
182
+ it('returns null (snapshotId is an observation, not a declared field, and has no dedicated event)', () => {
183
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'old-sha' }] });
184
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'new-sha' }] });
185
+
186
+ expect(determineEventType(prev, next)).to.equal(null);
187
+ });
188
+ });
189
+
190
+ context('when both snapshotId and a declared field differ', () => {
191
+ it('returns DECLARATION_UPDATED (the declared field change takes precedence)', () => {
192
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'old-sha' }] });
193
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://b.example', select: '.x', mimeType: 'text/html', snapshotId: 'new-sha' }] });
194
+
195
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.DECLARATION_UPDATED);
196
+ });
197
+ });
198
+
199
+ context('when snapshotId differs and mimeType also differs', () => {
200
+ it('returns MIME_TYPE_UPDATED (snapshotId change alone is ignored)', () => {
201
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'old-sha' }] });
202
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'application/pdf', snapshotId: 'new-sha' }] });
203
+
204
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.MIME_TYPE_UPDATED);
205
+ });
206
+ });
207
+
208
+ context('when only serviceName differs', () => {
209
+ it('returns SERVICE_NAME_UPDATED', () => {
210
+ expect(determineEventType(makeResult({ serviceName: 'Facebook' }), makeResult({ serviceName: 'Facebook Inc.' }))).to.equal(EVENT_TYPES.SERVICE_NAME_UPDATED);
211
+ });
212
+ });
213
+
214
+ context('when no substantive change', () => {
215
+ it('returns null', () => {
216
+ expect(determineEventType(makeResult(), makeResult())).to.equal(null);
217
+ });
218
+
219
+ context('when the same transient error reasons are present on both sides', () => {
220
+ it('returns null', () => {
221
+ const prev = makeResult({ transientError: { reasons: ['[fetch] HTTP 503'] } });
222
+ const next = makeResult({ transientError: { reasons: ['[fetch] HTTP 503'] } });
223
+
224
+ expect(determineEventType(prev, next)).to.equal(null);
225
+ });
226
+ });
227
+
228
+ context('when reasons order is the same on both sides', () => {
229
+ it('returns null (reasons are order-sensitive)', () => {
230
+ const prev = makeResult({ status: STATUSES.failed, reasons: [ '[fetch] HTTP 500', '[extraction] selector miss' ] });
231
+ const next = makeResult({ status: STATUSES.failed, reasons: [ '[fetch] HTTP 500', '[extraction] selector miss' ] });
232
+
233
+ expect(determineEventType(prev, next)).to.equal(null);
234
+ });
235
+ });
236
+ });
237
+
238
+ context('when source document count changes', () => {
239
+ it('returns DECLARATION_UPDATED when a document is added', () => {
240
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', mimeType: 'text/html' }] });
241
+ const next = makeResult({
242
+ sourceDocuments: [
243
+ { id: 'main', fetch: 'https://a.example', mimeType: 'text/html' },
244
+ { id: 'extra', fetch: 'https://b.example', mimeType: 'text/html' },
245
+ ],
246
+ });
247
+
248
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.DECLARATION_UPDATED);
249
+ });
250
+
251
+ it('returns DECLARATION_UPDATED when a document is removed', () => {
252
+ const prev = makeResult({
253
+ sourceDocuments: [
254
+ { id: 'main', fetch: 'https://a.example', mimeType: 'text/html' },
255
+ { id: 'extra', fetch: 'https://b.example', mimeType: 'text/html' },
256
+ ],
257
+ });
258
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', mimeType: 'text/html' }] });
259
+
260
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.DECLARATION_UPDATED);
261
+ });
262
+
263
+ it('returns DECLARATION_UPDATED when previous had no documents and new has one', () => {
264
+ const prev = makeResult({ sourceDocuments: [] });
265
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', mimeType: 'text/html' }] });
266
+
267
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.DECLARATION_UPDATED);
268
+ });
269
+ });
270
+
271
+ context('when reasons order changes (same elements, different order)', () => {
272
+ it('returns REASONS_CHANGED because reasons are order-sensitive', () => {
273
+ const prev = makeResult({ status: STATUSES.failed, reasons: [ '[fetch] HTTP 500', '[extraction] selector miss' ] });
274
+ const next = makeResult({ status: STATUSES.failed, reasons: [ '[extraction] selector miss', '[fetch] HTTP 500' ] });
275
+
276
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.REASONS_CHANGED);
277
+ });
278
+ });
279
+
280
+ context('when a deeply nested source document field changes', () => {
281
+ it('returns DECLARATION_UPDATED', () => {
282
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', options: { timeout: 5000 }, mimeType: 'text/html' }] });
283
+ const next = makeResult({ sourceDocuments: [{ id: 'main', options: { timeout: 9000 }, mimeType: 'text/html' }] });
284
+
285
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.DECLARATION_UPDATED);
286
+ });
287
+ });
288
+
289
+ context('priority', () => {
290
+ it('prioritises status transition over reasons change', () => {
291
+ const prev = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 500'] });
292
+ const next = makeResult();
293
+
294
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.TRACKING_RECOVERY);
295
+ });
296
+
297
+ it('prioritises status transition over transient error appearance', () => {
298
+ const prev = makeResult();
299
+ const next = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 500'], transientError: { reasons: ['[fetch] HTTP 503'] } });
300
+
301
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.TRACKING_FAILURE);
302
+ });
303
+
304
+ it('prioritises reasons change over declaration update for same failed status', () => {
305
+ const prev = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 500'], sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }] });
306
+ const next = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 404'], sourceDocuments: [{ id: 'main', fetch: 'https://b.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }] });
307
+
308
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.REASONS_CHANGED);
309
+ });
310
+
311
+ it('prioritises transient error appearance over declaration update', () => {
312
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }] });
313
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://b.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }], transientError: { reasons: ['[fetch] HTTP 503'] } });
314
+
315
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.TRANSIENT_ERROR_DETECTED);
316
+ });
317
+
318
+ it('prioritises declaration update over MIME type when both differ', () => {
319
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }] });
320
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.y', mimeType: 'application/pdf', snapshotId: 'abc' }] });
321
+
322
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.DECLARATION_UPDATED);
323
+ });
324
+
325
+ it('prioritises declaration update over service name when both differ', () => {
326
+ const prev = makeResult({ serviceName: 'Facebook', sourceDocuments: [{ id: 'main', fetch: 'https://a.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }] });
327
+ const next = makeResult({ serviceName: 'Facebook Inc.', sourceDocuments: [{ id: 'main', fetch: 'https://b.example', select: '.x', mimeType: 'text/html', snapshotId: 'abc' }] });
328
+
329
+ expect(determineEventType(prev, next)).to.equal(EVENT_TYPES.DECLARATION_UPDATED);
330
+ });
331
+ });
332
+ });
333
+
334
+ describe('TRANSITIONS_BY_EVENT_TYPE', () => {
335
+ it('maps only the true transition event types', () => {
336
+ expect(TRANSITIONS_BY_EVENT_TYPE).to.deep.equal({
337
+ [EVENT_TYPES.TRACKING_FAILURE]: 'newFailures',
338
+ [EVENT_TYPES.TRACKING_RECOVERY]: 'recoveries',
339
+ [EVENT_TYPES.REASONS_CHANGED]: 'reasonChanges',
340
+ });
341
+ });
342
+ });
343
+
344
+ describe('#formatMessage', () => {
345
+ const params = { serviceId: SERVICE_ID, termsType: TERMS_TYPE };
346
+
347
+ it('formats FIRST_TRACKING', () => {
348
+ expect(formatMessage(EVENT_TYPES.FIRST_TRACKING, params)).to.equal('Record first tracking of Facebook Terms of Service');
349
+ });
350
+
351
+ it('formats TRACKING_FAILURE with fetch type', () => {
352
+ expect(formatMessage(EVENT_TYPES.TRACKING_FAILURE, { ...params, failureType: 'fetch' })).to.equal('Record tracking failure of Facebook Terms of Service (fetch)');
353
+ });
354
+
355
+ it('formats FIRST_TRACKING_FAILURE as a tracking failure', () => {
356
+ expect(formatMessage(EVENT_TYPES.FIRST_TRACKING_FAILURE, { ...params, failureType: 'fetch' })).to.equal('Record tracking failure of Facebook Terms of Service (fetch)');
357
+ });
358
+
359
+ it('formats TRACKING_FAILURE with extraction type', () => {
360
+ expect(formatMessage(EVENT_TYPES.TRACKING_FAILURE, { ...params, failureType: 'extraction' })).to.equal('Record tracking failure of Facebook Terms of Service (extraction)');
361
+ });
362
+
363
+ it('formats TRACKING_FAILURE with internal type', () => {
364
+ expect(formatMessage(EVENT_TYPES.TRACKING_FAILURE, { ...params, failureType: 'internal' })).to.equal('Record tracking failure of Facebook Terms of Service (internal)');
365
+ });
366
+
367
+ it('formats TRACKING_RECOVERY', () => {
368
+ expect(formatMessage(EVENT_TYPES.TRACKING_RECOVERY, params)).to.equal('Record tracking recovery of Facebook Terms of Service');
369
+ });
370
+
371
+ it('formats REASONS_CHANGED', () => {
372
+ expect(formatMessage(EVENT_TYPES.REASONS_CHANGED, params)).to.equal('Update failure reasons of Facebook Terms of Service');
373
+ });
374
+
375
+ it('formats TRANSIENT_ERROR_DETECTED', () => {
376
+ expect(formatMessage(EVENT_TYPES.TRANSIENT_ERROR_DETECTED, params)).to.equal('Record transient error of Facebook Terms of Service');
377
+ });
378
+
379
+ it('formats TRANSIENT_ERROR_RESOLVED', () => {
380
+ expect(formatMessage(EVENT_TYPES.TRANSIENT_ERROR_RESOLVED, params)).to.equal('Clear transient error of Facebook Terms of Service');
381
+ });
382
+
383
+ it('formats DECLARATION_UPDATED', () => {
384
+ expect(formatMessage(EVENT_TYPES.DECLARATION_UPDATED, params)).to.equal('Update tracking declaration of Facebook Terms of Service');
385
+ });
386
+
387
+ it('formats SERVICE_NAME_UPDATED', () => {
388
+ expect(formatMessage(EVENT_TYPES.SERVICE_NAME_UPDATED, params)).to.equal('Update service name of Facebook Terms of Service');
389
+ });
390
+
391
+ it('formats MIME_TYPE_UPDATED', () => {
392
+ expect(formatMessage(EVENT_TYPES.MIME_TYPE_UPDATED, params)).to.equal('Update MIME type of Facebook Terms of Service');
393
+ });
394
+
395
+ it('throws for unknown event type', () => {
396
+ expect(() => formatMessage('nonsense', params)).to.throw(/Unknown tracking-result event type/);
397
+ });
398
+ });
399
+
400
+ describe('#toPersistence', () => {
401
+ context('when there is no substantive change', () => {
402
+ it('returns null', () => {
403
+ expect(toPersistence(makeResult(), makeResult())).to.be.null;
404
+ });
405
+ });
406
+
407
+ context('on first record', () => {
408
+ let persistence;
409
+
410
+ before(() => {
411
+ persistence = toPersistence(makeResult(), null);
412
+ });
413
+
414
+ it('uses the first-tracking commit subject', () => {
415
+ expect(persistence.message).to.equal('Record first tracking of Facebook Terms of Service');
416
+ });
417
+
418
+ it('serialises status and event as pretty JSON terminated by a newline', () => {
419
+ expect(persistence.content).to.equal(`${JSON.stringify({
420
+ status: 'ok',
421
+ event: {
422
+ date: '2026-01-10T10:30:00Z',
423
+ serviceName: 'Facebook',
424
+ sourceDocuments: SOURCE_DOCUMENTS,
425
+ },
426
+ }, null, 2)}\n`);
427
+ });
428
+
429
+ it('uses the per-terms file path', () => {
430
+ expect(persistence.filePath).to.equal('Facebook/Terms of Service.json');
431
+ });
432
+
433
+ it('uses the event date', () => {
434
+ expect(persistence.date).to.equal('2026-01-10T10:30:00Z');
435
+ });
436
+ });
437
+
438
+ context('on first record of a failure', () => {
439
+ it('uses the tracking-failure commit subject', () => {
440
+ const persistence = toPersistence(makeResult({ status: STATUSES.failed, reasons: ['[extraction] no match'] }), null);
441
+
442
+ expect(persistence.message).to.equal('Record tracking failure of Facebook Terms of Service (extraction)');
443
+ });
444
+ });
445
+
446
+ context('on tracking failure', () => {
447
+ let persistence;
448
+
449
+ before(() => {
450
+ persistence = toPersistence(makeResult({ status: STATUSES.failed, reasons: [ '[extraction] selector miss', '[fetch] HTTP 500' ] }), makeResult());
451
+ });
452
+
453
+ it('appends the failure type derived from reasons to the subject', () => {
454
+ expect(persistence.message).to.equal('Record tracking failure of Facebook Terms of Service (fetch)');
455
+ });
456
+
457
+ it('includes the reasons in the persisted content', () => {
458
+ const parsed = JSON.parse(persistence.content);
459
+
460
+ expect(parsed.status).to.equal('failed');
461
+ expect(parsed.event.reasons).to.deep.equal([ '[extraction] selector miss', '[fetch] HTTP 500' ]);
462
+ });
463
+ });
464
+
465
+ context('when a transient error is present on an ok status', () => {
466
+ it('includes the transientError in the persisted content', () => {
467
+ const persistence = toPersistence(makeResult({ transientError: { reasons: ['[fetch] HTTP 503'] } }), null);
468
+ const parsed = JSON.parse(persistence.content);
469
+
470
+ expect(parsed.event.transientError).to.deep.equal({ reasons: ['[fetch] HTTP 503'] });
471
+ });
472
+ });
473
+
474
+ context('on tracking recovery', () => {
475
+ it('uses the recovery commit subject', () => {
476
+ const prev = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 500'] });
477
+ const persistence = toPersistence(makeResult(), prev);
478
+
479
+ expect(persistence.message).to.equal('Record tracking recovery of Facebook Terms of Service');
480
+ });
481
+
482
+ it('does not include reasons in the persisted content', () => {
483
+ const prev = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 500'] });
484
+ const persistence = toPersistence(makeResult(), prev);
485
+ const parsed = JSON.parse(persistence.content);
486
+
487
+ expect(parsed.event.reasons).to.be.undefined;
488
+ expect(parsed.status).to.equal('ok');
489
+ });
490
+ });
491
+
492
+ context('on reasons change', () => {
493
+ it('uses the reasons-changed commit subject and reflects the new reasons', () => {
494
+ const prev = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 500'] });
495
+ const next = makeResult({ status: STATUSES.failed, reasons: ['[fetch] HTTP 404'] });
496
+ const persistence = toPersistence(next, prev);
497
+
498
+ expect(persistence.message).to.equal('Update failure reasons of Facebook Terms of Service');
499
+ expect(JSON.parse(persistence.content).event.reasons).to.deep.equal(['[fetch] HTTP 404']);
500
+ });
501
+ });
502
+
503
+ context('on transient error resolved', () => {
504
+ it('uses the clear-transient-error commit subject and omits transientError from content', () => {
505
+ const prev = makeResult({ transientError: { reasons: ['[fetch] HTTP 503'] } });
506
+ const persistence = toPersistence(makeResult(), prev);
507
+
508
+ expect(persistence.message).to.equal('Clear transient error of Facebook Terms of Service');
509
+ expect(JSON.parse(persistence.content).event.transientError).to.be.undefined;
510
+ });
511
+ });
512
+
513
+ context('on declaration updated', () => {
514
+ it('uses the declaration-update commit subject', () => {
515
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', mimeType: 'text/html', snapshotId: 'abc' }] });
516
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://b.example', mimeType: 'text/html', snapshotId: 'abc' }] });
517
+ const persistence = toPersistence(next, prev);
518
+
519
+ expect(persistence.message).to.equal('Update tracking declaration of Facebook Terms of Service');
520
+ });
521
+ });
522
+
523
+ context('on MIME type updated', () => {
524
+ it('uses the mime-type-update commit subject and reflects the new mimeType', () => {
525
+ const prev = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', mimeType: 'text/html', snapshotId: 'abc' }] });
526
+ const next = makeResult({ sourceDocuments: [{ id: 'main', fetch: 'https://a.example', mimeType: 'application/pdf', snapshotId: 'abc' }] });
527
+ const persistence = toPersistence(next, prev);
528
+
529
+ expect(persistence.message).to.equal('Update MIME type of Facebook Terms of Service');
530
+ expect(JSON.parse(persistence.content).event.sourceDocuments[0].mimeType).to.equal('application/pdf');
531
+ });
532
+ });
533
+
534
+ context('on service name updated', () => {
535
+ it('uses the service-name-update commit subject, keyed by the stable serviceId', () => {
536
+ const prev = makeResult({ serviceName: 'Facebook' });
537
+ const next = makeResult({ serviceName: 'Facebook Inc.' });
538
+ const persistence = toPersistence(next, prev);
539
+
540
+ expect(persistence.message).to.equal('Update service name of Facebook Terms of Service'); // The subject carries the serviceId, not the (new) serviceName, so grepping the history by id spans the rename
541
+ });
542
+ });
543
+ });
544
+
545
+ describe('#toDomain', () => {
546
+ it('rebuilds a TermsResult from persisted data', () => {
547
+ const data = {
548
+ status: 'ok',
549
+ event: { date: '2026-01-10T10:30:00Z', serviceName: 'Google', sourceDocuments: SOURCE_DOCUMENTS },
550
+ };
551
+
552
+ const result = toDomain({ serviceId: 'Google', termsType: 'Privacy Policy', data });
553
+
554
+ expect(result).to.be.instanceOf(TermsResult);
555
+ expect(result.serviceId).to.equal('Google');
556
+ expect(result.termsType).to.equal('Privacy Policy');
557
+ expect(result.status).to.equal('ok');
558
+ expect(result.event.date).to.equal('2026-01-10T10:30:00Z');
559
+ });
560
+
561
+ context('when persisted data is invalid', () => {
562
+ it('throws an error contextualised with the service and terms type', () => {
563
+ const data = { status: 'unknown', event: { date: '2026-01-10T10:30:00Z', serviceName: 'Google', sourceDocuments: SOURCE_DOCUMENTS } };
564
+
565
+ expect(() => toDomain({ serviceId: 'Google', termsType: 'Privacy Policy', data })).to.throw(/Invalid TermsResult content for Google\/Privacy Policy/);
566
+ });
567
+
568
+ it('throws when a required event field is missing', () => {
569
+ const data = { status: 'ok', event: { date: '2026-01-10T10:30:00Z', sourceDocuments: SOURCE_DOCUMENTS } };
570
+
571
+ expect(() => toDomain({ serviceId: 'Google', termsType: 'Privacy Policy', data })).to.throw(/Invalid TermsResult content for Google\/Privacy Policy/);
572
+ });
573
+ });
574
+ });
575
+ });
@@ -0,0 +1,42 @@
1
+ export const STATUSES = Object.freeze({ ok: 'ok', failed: 'failed' });
2
+
3
+ const VALID_STATUSES = Object.freeze(Object.values(STATUSES));
4
+
5
+ export default class TermsResult {
6
+ static REQUIRED_PARAMS = Object.freeze([ 'serviceId', 'termsType', 'status', 'event' ]);
7
+ static REQUIRED_EVENT_FIELDS = Object.freeze([ 'date', 'serviceName' ]);
8
+
9
+ constructor(params) {
10
+ Object.assign(this, params && JSON.parse(JSON.stringify(params))); // Normalise to the JSON round-trip so the in-memory instance always matches its persisted-then-re-read form (undefined-valued keys are dropped, as JSON.stringify would drop them at write time); change detection compares both sides with a key-sensitive deep equality
11
+ }
12
+
13
+ validate() {
14
+ for (const requiredParam of this.constructor.REQUIRED_PARAMS) {
15
+ if (!Object.prototype.hasOwnProperty.call(this, requiredParam) || this[requiredParam] == null) {
16
+ throw new Error(`${this.constructor.name} is not valid; "${requiredParam}" is missing`);
17
+ }
18
+ }
19
+
20
+ if (!VALID_STATUSES.includes(this.status)) {
21
+ throw new Error(`${this.constructor.name} is not valid; "status" must be one of "${VALID_STATUSES.join('", "')}", got "${this.status}"`);
22
+ }
23
+
24
+ for (const eventField of this.constructor.REQUIRED_EVENT_FIELDS) {
25
+ if (this.event[eventField] == null) {
26
+ throw new Error(`${this.constructor.name} is not valid; "event.${eventField}" is missing`);
27
+ }
28
+ }
29
+
30
+ if (!Array.isArray(this.event.sourceDocuments)) {
31
+ throw new Error(`${this.constructor.name} is not valid; "event.sourceDocuments" must be an array`);
32
+ }
33
+
34
+ if (this.status === STATUSES.failed && (!Array.isArray(this.event.reasons) || this.event.reasons.length === 0)) {
35
+ throw new Error(`${this.constructor.name} is not valid; "event.reasons" must be a non-empty array when status is "failed"`);
36
+ }
37
+
38
+ if (this.event.transientError && (!Array.isArray(this.event.transientError.reasons) || this.event.transientError.reasons.length === 0)) {
39
+ throw new Error(`${this.constructor.name} is not valid; "event.transientError.reasons" must be a non-empty array`);
40
+ }
41
+ }
42
+ }