@opentermsarchive/engine 7.1.0 → 7.2.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.
@@ -1,14 +1,506 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
1
4
  import chai from 'chai';
2
- import chaiExclude from 'chai-exclude';
5
+ import sinon from 'sinon';
6
+ import sinonChai from 'sinon-chai';
3
7
 
4
8
  import expectedServices from '../../../test/fixtures/services.js';
9
+ import * as exposedFilters from '../extract/exposedFilters.js';
10
+
11
+ import Service from './service.js';
12
+ import SourceDocument from './sourceDocument.js';
13
+ import Terms from './terms.js';
5
14
 
6
- import * as services from './index.js';
15
+ import { getDeclaredServicesIds, loadServiceDeclaration, loadServiceFilters, getServiceFilters, createSourceDocuments, createServiceFromDeclaration, load, loadWithHistory } from './index.js';
7
16
 
8
- chai.use(chaiExclude);
17
+ chai.use(sinonChai);
9
18
  const { expect } = chai;
10
19
 
11
20
  describe('Services', () => {
21
+ describe('#getDeclaredServicesIds', () => {
22
+ let readdir;
23
+
24
+ beforeEach(() => {
25
+ readdir = sinon.stub(fs, 'readdir');
26
+ });
27
+
28
+ afterEach(() => {
29
+ sinon.restore();
30
+ });
31
+
32
+ context('with valid JSON service declarations', () => {
33
+ beforeEach(() => {
34
+ readdir.resolves([
35
+ 'serviceA.json',
36
+ 'serviceB.json',
37
+ 'serviceC.json',
38
+ ]);
39
+ });
40
+
41
+ it('returns service IDs from JSON files', async () => {
42
+ const serviceIds = await getDeclaredServicesIds();
43
+
44
+ expect(serviceIds).to.deep.equal([ 'serviceA', 'serviceB', 'serviceC' ]);
45
+ });
46
+ });
47
+
48
+ context('with mixed file types', () => {
49
+ beforeEach(() => {
50
+ readdir.resolves([
51
+ 'serviceA.json',
52
+ 'serviceB.txt',
53
+ 'serviceC.js',
54
+ 'serviceD.json',
55
+ 'readme.md',
56
+ ]);
57
+ });
58
+
59
+ it('filters out non-JSON files', async () => {
60
+ const serviceIds = await getDeclaredServicesIds();
61
+
62
+ expect(serviceIds).to.deep.equal([ 'serviceA', 'serviceD' ]);
63
+ });
64
+ });
65
+
66
+ context('with history files', () => {
67
+ beforeEach(() => {
68
+ readdir.resolves([
69
+ 'serviceA.json',
70
+ 'serviceA.history.json',
71
+ 'serviceB.json',
72
+ 'serviceB.history.json',
73
+ 'serviceC.json',
74
+ ]);
75
+ });
76
+
77
+ it('excludes history files', async () => {
78
+ const serviceIds = await getDeclaredServicesIds();
79
+
80
+ expect(serviceIds).to.deep.equal([ 'serviceA', 'serviceB', 'serviceC' ]);
81
+ });
82
+ });
83
+
84
+ context('with complex filenames', () => {
85
+ beforeEach(() => {
86
+ readdir.resolves([
87
+ 'service-with-dashes.json',
88
+ 'Service With Spaces.json',
89
+ 'service·A.json',
90
+ 'Service B!.json',
91
+ 'service_with_underscores.json',
92
+ 'service.with.dots.json',
93
+ ]);
94
+ });
95
+
96
+ it('handles complex service ID patterns', async () => {
97
+ const serviceIds = await getDeclaredServicesIds();
98
+
99
+ expect(serviceIds).to.deep.equal([
100
+ 'service-with-dashes',
101
+ 'Service With Spaces',
102
+ 'service·A',
103
+ 'Service B!',
104
+ 'service_with_underscores',
105
+ 'service.with.dots',
106
+ ]);
107
+ });
108
+ });
109
+
110
+ context('with an empty directory', () => {
111
+ beforeEach(() => {
112
+ readdir.resolves([]);
113
+ });
114
+
115
+ it('returns an empty array', async () => {
116
+ const serviceIds = await getDeclaredServicesIds();
117
+
118
+ expect(serviceIds).to.deep.equal([]);
119
+ });
120
+ });
121
+
122
+ context('when directory read fails', () => {
123
+ beforeEach(() => {
124
+ readdir.rejects(new Error('ENOENT: no such file or directory'));
125
+ });
126
+
127
+ it('throws the original error', async () => {
128
+ await expect(getDeclaredServicesIds()).to.be.rejectedWith('ENOENT: no such file or directory');
129
+ });
130
+ });
131
+
132
+ context('with JSON files containing "history" but not as suffix', () => {
133
+ beforeEach(() => {
134
+ readdir.resolves([
135
+ 'history-service.json',
136
+ 'service-history.json',
137
+ 'service.history.json',
138
+ 'normal-service.json',
139
+ ]);
140
+ });
141
+
142
+ it('excludes only files with .history.json suffix', async () => {
143
+ const serviceIds = await getDeclaredServicesIds();
144
+
145
+ expect(serviceIds).to.deep.equal([
146
+ 'history-service',
147
+ 'service-history',
148
+ 'normal-service',
149
+ ]);
150
+ });
151
+ });
152
+ });
153
+
154
+ describe('#loadServiceDeclaration', () => {
155
+ let readFile;
156
+
157
+ beforeEach(() => {
158
+ readFile = sinon.stub(fs, 'readFile');
159
+ });
160
+
161
+ afterEach(() => {
162
+ sinon.restore();
163
+ });
164
+
165
+ context('with valid JSON service declaration', () => {
166
+ const serviceId = 'serviceA';
167
+ const validDeclaration = {
168
+ name: 'Service A',
169
+ terms: {
170
+ 'Terms of Service': {
171
+ fetch: 'https://example.com/tos',
172
+ select: 'body',
173
+ },
174
+ },
175
+ };
176
+
177
+ beforeEach(() => {
178
+ readFile.resolves(JSON.stringify(validDeclaration));
179
+ });
180
+
181
+ it('returns the parsed service declaration', async () => {
182
+ const result = await loadServiceDeclaration(serviceId);
183
+
184
+ expect(result).to.deep.equal(validDeclaration);
185
+ });
186
+
187
+ it('reads from the correct file path', async () => {
188
+ await loadServiceDeclaration(serviceId);
189
+
190
+ expect(readFile).to.have.been.calledWith(sinon.match(filePath => filePath.endsWith(`${path.sep}serviceA.json`)));
191
+ });
192
+ });
193
+
194
+ context('when the declaration contains invalid JSON', () => {
195
+ const serviceId = 'invalidJson';
196
+
197
+ beforeEach(() => {
198
+ readFile.resolves('{ invalid json content');
199
+ });
200
+
201
+ it('throws with a descriptive error message', async () => {
202
+ try {
203
+ await loadServiceDeclaration(serviceId);
204
+ expect.fail('Expected function to throw an error');
205
+ } catch (error) {
206
+ expect(error.message).to.include('The "invalidJson" service declaration is malformed and cannot be parsed');
207
+ expect(error.message).to.include('invalidJson.json');
208
+ }
209
+ });
210
+ });
211
+ });
212
+
213
+ describe('#loadServiceFilters', () => {
214
+ let access;
215
+
216
+ beforeEach(() => {
217
+ access = sinon.stub(fs, 'access');
218
+ });
219
+
220
+ afterEach(() => {
221
+ sinon.restore();
222
+ });
223
+
224
+ context('when service filters file does not exist', () => {
225
+ const serviceId = 'serviceWithoutFilters';
226
+
227
+ beforeEach(() => {
228
+ const error = new Error('ENOENT: no such file or directory');
229
+
230
+ error.code = 'ENOENT';
231
+ access.rejects(error);
232
+ });
233
+
234
+ it('returns an empty object', async () => {
235
+ const result = await loadServiceFilters(serviceId);
236
+
237
+ expect(result).to.be.empty;
238
+ });
239
+
240
+ it('checks for file existence', async () => {
241
+ await loadServiceFilters(serviceId);
242
+
243
+ expect(access).to.have.been.calledWith(sinon.match(filePath => filePath.endsWith(`${path.sep}serviceWithoutFilters.filters.js`)));
244
+ });
245
+ });
246
+
247
+ context('with a service that has a filters file', () => {
248
+ let result;
249
+
250
+ before(async () => {
251
+ result = await loadServiceFilters('service_with_filters_history');
252
+ });
253
+
254
+ it('has expected filters functions', () => {
255
+ expect(result.removePrintButton).to.be.a('function');
256
+ expect(result.removeShareButton).to.be.a('function');
257
+ expect(result.removePrintButton.name).to.equal('removePrintButton');
258
+ expect(result.removeShareButton.name).to.equal('removeShareButton');
259
+ });
260
+ });
261
+ });
262
+
263
+ describe('#getServiceFilters', () => {
264
+ it('returns undefined if filterNames is falsy', () => {
265
+ const result = getServiceFilters({}, undefined);
266
+
267
+ expect(result).to.be.undefined;
268
+ });
269
+
270
+ it('returns filters from exposedFilters by string name', () => {
271
+ const filterNames = Object.keys(exposedFilters);
272
+
273
+ const filterName = filterNames[0];
274
+ const result = getServiceFilters({}, [filterName]);
275
+
276
+ expect(result).to.deep.equal([exposedFilters[filterName]]);
277
+ });
278
+
279
+ it('returns filters from serviceFilters by string name', () => {
280
+ const serviceFilters = { custom: () => 'custom' };
281
+ const result = getServiceFilters(serviceFilters, ['custom']);
282
+
283
+ expect(result).to.deep.equal([serviceFilters.custom]);
284
+ });
285
+
286
+ it('returns undefined for unknown filter names', () => {
287
+ const result = getServiceFilters({}, ['notFound']);
288
+
289
+ expect(result).to.be.undefined;
290
+ });
291
+
292
+ it('wraps object-based filter config and preserves function name', () => {
293
+ const paramFilter = (dom, param) => param;
294
+ const serviceFilters = { paramFilter };
295
+ const result = getServiceFilters(serviceFilters, [{ paramFilter: 'foo' }]);
296
+
297
+ expect(result[0]).to.be.a('function');
298
+ expect(result[0].name).to.equal('paramFilter');
299
+ expect(result[0](null, 'context')).to.equal('foo');
300
+ });
301
+
302
+ describe('parameters passed to filters', () => {
303
+ let serviceLoadedFilters;
304
+ let passedDOM;
305
+ let passedContext;
306
+
307
+ before(() => {
308
+ serviceLoadedFilters = { testParamsFilter: (dom, params, context) => ({ dom, params, context }) };
309
+ passedDOM = '<div>test</div>';
310
+ passedContext = { location: 'https://example.com' };
311
+ });
312
+
313
+ const testParameterPassing = params => {
314
+ const serviceDeclaredFilters = [{ testParamsFilter: params }];
315
+ const [loadedFilter] = getServiceFilters(serviceLoadedFilters, serviceDeclaredFilters);
316
+ const filterResult = loadedFilter(passedDOM, passedContext);
317
+
318
+ expect(filterResult.params).to.deep.equal(params);
319
+ expect(filterResult.dom).to.equal(passedDOM);
320
+ expect(filterResult.context).to.equal(passedContext);
321
+ };
322
+
323
+ context('as a string', () => {
324
+ it('passes parameters correctly', () => {
325
+ testParameterPassing('param');
326
+ });
327
+ });
328
+
329
+ context('as an array', () => {
330
+ it('passes parameters correctly', () => {
331
+ testParameterPassing([ 'param1', 'param2' ]);
332
+ });
333
+ });
334
+
335
+ context('as an object', () => {
336
+ it('passes parameters correctly', () => {
337
+ testParameterPassing({ param1: 'param1', param2: 'param2' });
338
+ });
339
+ });
340
+ });
341
+ });
342
+
343
+ describe('#createSourceDocuments', () => {
344
+ const realFilterNames = Object.keys(exposedFilters);
345
+ const SERVICE_ID = 'service_with_filters_history';
346
+ let result;
347
+
348
+ context('when terms declaration has only one source document', () => {
349
+ const termsDeclaration = {
350
+ fetch: 'https://example.com/terms',
351
+ executeClientScripts: true,
352
+ select: 'body',
353
+ remove: '.ads',
354
+ filter: [
355
+ realFilterNames[0],
356
+ 'removePrintButton',
357
+ ],
358
+ };
359
+
360
+ before(async () => {
361
+ result = await createSourceDocuments(SERVICE_ID, termsDeclaration);
362
+ });
363
+
364
+ it('creates a single SourceDocument', () => {
365
+ expect(result).to.have.length(1);
366
+ expect(result[0]).to.be.instanceOf(SourceDocument);
367
+ });
368
+
369
+ it('resolves both exposed and custom filters', () => {
370
+ const sourceDocument = result[0];
371
+
372
+ expect(sourceDocument.filters).to.be.an('array');
373
+ expect(sourceDocument.filters).to.have.length(2);
374
+ expect(sourceDocument.filters[0]).to.be.a('function');
375
+ expect(sourceDocument.filters[0]).to.equal(exposedFilters[realFilterNames[0]]);
376
+ expect(sourceDocument.filters[1]).to.be.a('function');
377
+ expect(sourceDocument.filters[1].name).to.equal('removePrintButton');
378
+ });
379
+
380
+ it('creates a SourceDocument with the correct properties', () => {
381
+ const sourceDocument = result[0];
382
+
383
+ expect(sourceDocument.location).to.equal(termsDeclaration.fetch);
384
+ expect(sourceDocument.executeClientScripts).to.equal(termsDeclaration.executeClientScripts);
385
+ expect(sourceDocument.contentSelectors).to.equal(termsDeclaration.select);
386
+ expect(sourceDocument.insignificantContentSelectors).to.equal(termsDeclaration.remove);
387
+ });
388
+ });
389
+
390
+ context('when terms declaration has multiple source documents', () => {
391
+ const termsDeclaration = {
392
+ fetch: 'https://example.com/base',
393
+ executeClientScripts: false,
394
+ select: 'body',
395
+ remove: '.base-ads',
396
+ filter: [
397
+ realFilterNames[0],
398
+ 'removePrintButton',
399
+ ],
400
+ combine: [
401
+ {
402
+ fetch: 'https://example.com/doc1',
403
+ select: '.content',
404
+ filter: ['removeShareButton'],
405
+ },
406
+ {
407
+ executeClientScripts: true,
408
+ remove: '.doc2-ads',
409
+ },
410
+ ],
411
+ };
412
+
413
+ before(async () => {
414
+ result = await createSourceDocuments(SERVICE_ID, termsDeclaration);
415
+ });
416
+
417
+ it('creates multiple SourceDocuments', () => {
418
+ expect(result).to.have.length(2);
419
+ expect(result[0]).to.be.instanceOf(SourceDocument);
420
+ expect(result[1]).to.be.instanceOf(SourceDocument);
421
+ });
422
+
423
+ it('resolves both exposed and custom filters', () => {
424
+ const firstSourceDocument = result[0];
425
+ const secondSourceDocument = result[1];
426
+
427
+ expect(firstSourceDocument.filters).to.be.an('array');
428
+ expect(firstSourceDocument.filters).to.have.length(1);
429
+ expect(firstSourceDocument.filters[0]).to.be.a('function');
430
+ expect(firstSourceDocument.filters[0].name).to.equal('removeShareButton');
431
+
432
+ expect(secondSourceDocument.filters).to.be.an('array');
433
+ expect(secondSourceDocument.filters).to.have.length(2);
434
+ expect(secondSourceDocument.filters[0]).to.be.a('function');
435
+ expect(secondSourceDocument.filters[0].name).to.equal('removeQueryParams');
436
+ expect(secondSourceDocument.filters[1]).to.be.a('function');
437
+ expect(secondSourceDocument.filters[1].name).to.equal('removePrintButton');
438
+ });
439
+
440
+ it('combines base properties and source document specific properties correctly', () => {
441
+ const firstSourceDocument = result[0];
442
+
443
+ expect(firstSourceDocument.location).to.equal('https://example.com/doc1');
444
+ expect(firstSourceDocument.contentSelectors).to.equal('.content');
445
+ expect(firstSourceDocument.executeClientScripts).to.equal(false);
446
+ expect(firstSourceDocument.insignificantContentSelectors).to.equal('.base-ads');
447
+
448
+ const secondSourceDocument = result[1];
449
+
450
+ expect(secondSourceDocument.location).to.equal(termsDeclaration.fetch);
451
+ expect(secondSourceDocument.contentSelectors).to.equal(termsDeclaration.select);
452
+ expect(secondSourceDocument.executeClientScripts).to.equal(true);
453
+ expect(secondSourceDocument.insignificantContentSelectors).to.equal('.doc2-ads');
454
+ });
455
+ });
456
+ });
457
+
458
+ describe('#createServiceFromDeclaration', () => {
459
+ const serviceId = 'service·A';
460
+ let result;
461
+
462
+ before(async () => {
463
+ result = await createServiceFromDeclaration(serviceId);
464
+ });
465
+
466
+ it('creates a Service instance', () => {
467
+ expect(result).to.be.instanceOf(Service);
468
+ });
469
+
470
+ it('sets correct service id', () => {
471
+ expect(result.id).to.equal(serviceId);
472
+ });
473
+
474
+ it('sets correct service name', () => {
475
+ expect(result.name).to.equal('Service·A');
476
+ });
477
+
478
+ it('adds Terms for the declared terms type', () => {
479
+ const terms = result.getTerms({ type: 'Terms of Service' });
480
+
481
+ expect(terms).to.be.instanceOf(Terms);
482
+ expect(terms.type).to.equal('Terms of Service');
483
+ });
484
+
485
+ it('creates Terms with correct source documents', () => {
486
+ const terms = result.getTerms({ type: 'Terms of Service' });
487
+
488
+ expect(terms.sourceDocuments).to.be.an('array');
489
+ expect(terms.sourceDocuments).to.have.length(1);
490
+ expect(terms.sourceDocuments[0]).to.be.instanceOf(SourceDocument);
491
+ expect(terms.sourceDocuments[0]).to.deep.equal({
492
+ location: 'https://www.servicea.example/tos',
493
+ executeClientScripts: undefined,
494
+ contentSelectors: 'body',
495
+ insignificantContentSelectors: undefined,
496
+ filters: undefined,
497
+ content: undefined,
498
+ mimeType: undefined,
499
+ id: 'tos',
500
+ });
501
+ });
502
+ });
503
+
12
504
  describe('#load', () => {
13
505
  let result;
14
506
 
@@ -94,7 +586,7 @@ describe('Services', () => {
94
586
  }
95
587
 
96
588
  before(async () => {
97
- result = await services.load();
589
+ result = await load();
98
590
  });
99
591
 
100
592
  describe('Service·A', async () => {
@@ -127,7 +619,7 @@ describe('Services', () => {
127
619
 
128
620
  context('when specifying services to load', () => {
129
621
  before(async () => {
130
- result = await services.load([ 'service·A', 'Service B!' ]);
622
+ result = await load([ 'service·A', 'Service B!' ]);
131
623
  });
132
624
 
133
625
  it('loads only the given services', () => {
@@ -263,7 +755,7 @@ describe('Services', () => {
263
755
  }
264
756
 
265
757
  before(async () => {
266
- result = await services.loadWithHistory();
758
+ result = await loadWithHistory();
267
759
  });
268
760
 
269
761
  describe('Service·A', async () => {
@@ -296,7 +788,7 @@ describe('Services', () => {
296
788
 
297
789
  context('when specifying services to load', () => {
298
790
  before(async () => {
299
- result = await services.loadWithHistory([ 'service·A', 'Service B!' ]);
791
+ result = await loadWithHistory([ 'service·A', 'Service B!' ]);
300
792
  });
301
793
 
302
794
  it('loads only the given services', () => {