@opentermsarchive/engine 11.0.1 → 12.0.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.
@@ -88,13 +88,76 @@ export default class MongoRepository extends RepositoryInterface {
88
88
  return this.#toDomain(mongoDocument);
89
89
  }
90
90
 
91
- async findAll() {
92
- return Promise.all((await this.collection.find().project({ content: 0 }).sort({ fetchDate: 1 }).toArray())
91
+ async findAll({ limit, offset, includeTechnicalUpgrades = true } = {}) {
92
+ const filter = includeTechnicalUpgrades ? {} : { isTechnicalUpgrade: { $ne: true } };
93
+ let query = this.collection.find(filter).project({ content: 0 }).sort({ fetchDate: -1 });
94
+
95
+ if (offset !== undefined) {
96
+ query = query.skip(offset);
97
+ }
98
+
99
+ if (limit !== undefined) {
100
+ query = query.limit(limit);
101
+ }
102
+
103
+ return Promise.all((await query.toArray())
104
+ .map(mongoDocument => this.#toDomain(mongoDocument, { deferContentLoading: true })));
105
+ }
106
+
107
+ async findByServiceAndTermsType(serviceId, termsType, { limit, offset, includeTechnicalUpgrades = true } = {}) {
108
+ const filter = { serviceId, termsType };
109
+
110
+ if (!includeTechnicalUpgrades) {
111
+ filter.isTechnicalUpgrade = { $ne: true };
112
+ }
113
+
114
+ let query = this.collection.find(filter).project({ content: 0 }).sort({ fetchDate: -1 });
115
+
116
+ if (offset !== undefined) {
117
+ query = query.skip(offset);
118
+ }
119
+
120
+ if (limit !== undefined) {
121
+ query = query.limit(limit);
122
+ }
123
+
124
+ return Promise.all((await query.toArray())
93
125
  .map(mongoDocument => this.#toDomain(mongoDocument, { deferContentLoading: true })));
94
126
  }
95
127
 
96
- count() {
97
- return this.collection.countDocuments();
128
+ async findByService(serviceId, { limit, offset, includeTechnicalUpgrades = true } = {}) {
129
+ const filter = { serviceId };
130
+
131
+ if (!includeTechnicalUpgrades) {
132
+ filter.isTechnicalUpgrade = { $ne: true };
133
+ }
134
+
135
+ let query = this.collection.find(filter).project({ content: 0 }).sort({ fetchDate: -1 });
136
+
137
+ if (offset !== undefined) {
138
+ query = query.skip(offset);
139
+ }
140
+
141
+ if (limit !== undefined) {
142
+ query = query.limit(limit);
143
+ }
144
+
145
+ return Promise.all((await query.toArray())
146
+ .map(mongoDocument => this.#toDomain(mongoDocument, { deferContentLoading: true })));
147
+ }
148
+
149
+ count(serviceId, termsType) {
150
+ const filter = {};
151
+
152
+ if (serviceId) {
153
+ filter.serviceId = serviceId;
154
+ }
155
+
156
+ if (termsType) {
157
+ filter.termsType = termsType;
158
+ }
159
+
160
+ return this.collection.countDocuments(filter);
98
161
  }
99
162
 
100
163
  async* iterate() {
@@ -629,45 +629,362 @@ describe('MongoRepository', () => {
629
629
  }
630
630
  });
631
631
 
632
- it('returns records in ascending order', () => {
633
- expect(records.map(record => record.fetchDate)).to.deep.equal([ FETCH_DATE_EARLIER, FETCH_DATE, FETCH_DATE_LATER ]);
632
+ it('returns records in descending order', () => {
633
+ expect(records.map(record => record.fetchDate)).to.deep.equal([ FETCH_DATE_LATER, FETCH_DATE, FETCH_DATE_EARLIER ]);
634
+ });
635
+
636
+ context('with includeTechnicalUpgrades: false', () => {
637
+ let filteredRecords;
638
+
639
+ before(async () => {
640
+ filteredRecords = await subject.findAll({ includeTechnicalUpgrades: false });
641
+ });
642
+
643
+ it('excludes technical upgrade records', () => {
644
+ expect(filteredRecords.length).to.equal(2);
645
+ });
646
+
647
+ it('only returns records that represent actual content changes', () => {
648
+ for (const record of filteredRecords) {
649
+ expect(record.isTechnicalUpgrade).to.not.be.true;
650
+ }
651
+ });
652
+
653
+ it('returns the expected records in descending order', () => {
654
+ expect(filteredRecords.map(record => record.fetchDate)).to.deep.equal([ FETCH_DATE_LATER, FETCH_DATE ]);
655
+ });
634
656
  });
635
657
  });
636
658
 
637
- describe('#count', () => {
638
- let count;
659
+ describe('#findByServiceAndTermsType', () => {
660
+ const expectedIds = [];
661
+ let records;
639
662
 
640
663
  before(async () => {
641
- await subject.save(new Version({
664
+ const { id: id1 } = await subject.save(new Version({
642
665
  serviceId: SERVICE_PROVIDER_ID,
643
666
  termsType: TERMS_TYPE,
644
667
  content: CONTENT,
645
668
  fetchDate: FETCH_DATE,
646
669
  snapshotIds: [SNAPSHOT_ID],
647
670
  }));
648
- await subject.save(new Version({
671
+
672
+ expectedIds.push(id1);
673
+
674
+ const { id: id2 } = await subject.save(new Version({
649
675
  serviceId: SERVICE_PROVIDER_ID,
650
676
  termsType: TERMS_TYPE,
651
677
  content: `${CONTENT} - updated`,
652
678
  fetchDate: FETCH_DATE_LATER,
653
679
  snapshotIds: [SNAPSHOT_ID],
654
680
  }));
681
+
682
+ expectedIds.push(id2);
683
+
655
684
  await subject.save(new Version({
685
+ serviceId: 'other_service',
686
+ termsType: 'Privacy Policy',
687
+ content: `${CONTENT} - other`,
688
+ fetchDate: FETCH_DATE,
689
+ snapshotIds: [SNAPSHOT_ID],
690
+ }));
691
+
692
+ (records = await subject.findByServiceAndTermsType(SERVICE_PROVIDER_ID, TERMS_TYPE));
693
+ });
694
+
695
+ after(() => subject.removeAll());
696
+
697
+ it('returns only matching records', () => {
698
+ expect(records.length).to.equal(2);
699
+ });
700
+
701
+ it('returns Version objects', () => {
702
+ for (const record of records) {
703
+ expect(record).to.be.an.instanceof(Version);
704
+ }
705
+ });
706
+
707
+ it('returns records with matching service ID', () => {
708
+ for (const record of records) {
709
+ expect(record.serviceId).to.equal(SERVICE_PROVIDER_ID);
710
+ }
711
+ });
712
+
713
+ it('returns records with matching terms type', () => {
714
+ for (const record of records) {
715
+ expect(record.termsType).to.equal(TERMS_TYPE);
716
+ }
717
+ });
718
+
719
+ it('returns records in descending order', () => {
720
+ expect(records.map(record => record.fetchDate)).to.deep.equal([ FETCH_DATE_LATER, FETCH_DATE ]);
721
+ });
722
+
723
+ it('returns records with correct IDs', () => {
724
+ expect(records.map(record => record.id)).to.have.members(expectedIds);
725
+ });
726
+
727
+ context('when no matching records exist', () => {
728
+ it('returns an empty array', async () => {
729
+ const result = await subject.findByServiceAndTermsType('non_existent_service', 'Non Existent Terms');
730
+
731
+ expect(result).to.be.an('array').that.is.empty;
732
+ });
733
+ });
734
+
735
+ context('with includeTechnicalUpgrades: false', () => {
736
+ let filteredRecords;
737
+ let technicalUpgradeId;
738
+
739
+ before(async () => {
740
+ ({ id: technicalUpgradeId } = await subject.save(new Version({
741
+ serviceId: SERVICE_PROVIDER_ID,
742
+ termsType: TERMS_TYPE,
743
+ content: `${CONTENT} - technical upgrade`,
744
+ fetchDate: FETCH_DATE_EARLIER,
745
+ snapshotIds: [SNAPSHOT_ID],
746
+ isTechnicalUpgrade: true,
747
+ })));
748
+
749
+ filteredRecords = await subject.findByServiceAndTermsType(SERVICE_PROVIDER_ID, TERMS_TYPE, { includeTechnicalUpgrades: false });
750
+ });
751
+
752
+ it('excludes technical upgrade records', () => {
753
+ expect(filteredRecords.map(record => record.id)).to.not.include(technicalUpgradeId);
754
+ });
755
+
756
+ it('only returns records that represent actual content changes', () => {
757
+ for (const record of filteredRecords) {
758
+ expect(record.isTechnicalUpgrade).to.not.be.true;
759
+ }
760
+ });
761
+ });
762
+ });
763
+
764
+ describe('#findByService', () => {
765
+ const OTHER_TERMS_TYPE = 'Privacy Policy';
766
+ const expectedIds = [];
767
+ let records;
768
+
769
+ before(async () => {
770
+ const { id: id1 } = await subject.save(new Version({
656
771
  serviceId: SERVICE_PROVIDER_ID,
657
772
  termsType: TERMS_TYPE,
658
- content: `${CONTENT} - updated 2`,
659
- isTechnicalUpgrade: true,
773
+ content: CONTENT,
774
+ fetchDate: FETCH_DATE,
775
+ snapshotIds: [SNAPSHOT_ID],
776
+ }));
777
+
778
+ expectedIds.push(id1);
779
+
780
+ const { id: id2 } = await subject.save(new Version({
781
+ serviceId: SERVICE_PROVIDER_ID,
782
+ termsType: TERMS_TYPE,
783
+ content: `${CONTENT} - updated`,
784
+ fetchDate: FETCH_DATE_LATER,
785
+ snapshotIds: [SNAPSHOT_ID],
786
+ }));
787
+
788
+ expectedIds.push(id2);
789
+
790
+ const { id: id3 } = await subject.save(new Version({
791
+ serviceId: SERVICE_PROVIDER_ID,
792
+ termsType: OTHER_TERMS_TYPE,
793
+ content: `${CONTENT} - other terms type`,
660
794
  fetchDate: FETCH_DATE_EARLIER,
661
795
  snapshotIds: [SNAPSHOT_ID],
662
796
  }));
663
797
 
664
- (count = await subject.count());
798
+ expectedIds.push(id3);
799
+
800
+ await subject.save(new Version({
801
+ serviceId: 'other_service',
802
+ termsType: TERMS_TYPE,
803
+ content: `${CONTENT} - other service`,
804
+ fetchDate: FETCH_DATE,
805
+ snapshotIds: [SNAPSHOT_ID],
806
+ }));
807
+
808
+ (records = await subject.findByService(SERVICE_PROVIDER_ID));
665
809
  });
666
810
 
667
811
  after(() => subject.removeAll());
668
812
 
669
- it('returns the proper count', () => {
670
- expect(count).to.equal(3);
813
+ it('returns only matching records', () => {
814
+ expect(records.length).to.equal(3);
815
+ });
816
+
817
+ it('returns Version objects', () => {
818
+ for (const record of records) {
819
+ expect(record).to.be.an.instanceof(Version);
820
+ }
821
+ });
822
+
823
+ it('returns records with matching service ID', () => {
824
+ for (const record of records) {
825
+ expect(record.serviceId).to.equal(SERVICE_PROVIDER_ID);
826
+ }
827
+ });
828
+
829
+ it('returns records across all terms types of the service', () => {
830
+ expect(new Set(records.map(record => record.termsType))).to.deep.equal(new Set([ TERMS_TYPE, OTHER_TERMS_TYPE ]));
831
+ });
832
+
833
+ it('returns records in descending order', () => {
834
+ expect(records.map(record => record.fetchDate)).to.deep.equal([ FETCH_DATE_LATER, FETCH_DATE, FETCH_DATE_EARLIER ]);
835
+ });
836
+
837
+ it('returns records with correct IDs', () => {
838
+ expect(records.map(record => record.id)).to.have.members(expectedIds);
839
+ });
840
+
841
+ context('when no matching records exist', () => {
842
+ it('returns an empty array', async () => {
843
+ const result = await subject.findByService('non_existent_service');
844
+
845
+ expect(result).to.be.an('array').that.is.empty;
846
+ });
847
+ });
848
+
849
+ context('with includeTechnicalUpgrades: false', () => {
850
+ let filteredRecords;
851
+ let technicalUpgradeId;
852
+
853
+ before(async () => {
854
+ ({ id: technicalUpgradeId } = await subject.save(new Version({
855
+ serviceId: SERVICE_PROVIDER_ID,
856
+ termsType: TERMS_TYPE,
857
+ content: `${CONTENT} - technical upgrade`,
858
+ fetchDate: new Date('2000-01-03T12:00:00.000Z'),
859
+ snapshotIds: [SNAPSHOT_ID],
860
+ isTechnicalUpgrade: true,
861
+ })));
862
+
863
+ filteredRecords = await subject.findByService(SERVICE_PROVIDER_ID, { includeTechnicalUpgrades: false });
864
+ });
865
+
866
+ it('excludes technical upgrade records', () => {
867
+ expect(filteredRecords.map(record => record.id)).to.not.include(technicalUpgradeId);
868
+ });
869
+
870
+ it('only returns records that represent actual content changes', () => {
871
+ for (const record of filteredRecords) {
872
+ expect(record.isTechnicalUpgrade).to.not.be.true;
873
+ }
874
+ });
875
+ });
876
+ });
877
+
878
+ describe('#count', () => {
879
+ context('without filters', () => {
880
+ let count;
881
+
882
+ before(async () => {
883
+ await subject.save(new Version({
884
+ serviceId: SERVICE_PROVIDER_ID,
885
+ termsType: TERMS_TYPE,
886
+ content: CONTENT,
887
+ fetchDate: FETCH_DATE,
888
+ snapshotIds: [SNAPSHOT_ID],
889
+ }));
890
+ await subject.save(new Version({
891
+ serviceId: SERVICE_PROVIDER_ID,
892
+ termsType: TERMS_TYPE,
893
+ content: `${CONTENT} - updated`,
894
+ fetchDate: FETCH_DATE_LATER,
895
+ snapshotIds: [SNAPSHOT_ID],
896
+ }));
897
+ await subject.save(new Version({
898
+ serviceId: SERVICE_PROVIDER_ID,
899
+ termsType: TERMS_TYPE,
900
+ content: `${CONTENT} - updated 2`,
901
+ isTechnicalUpgrade: true,
902
+ fetchDate: FETCH_DATE_EARLIER,
903
+ snapshotIds: [SNAPSHOT_ID],
904
+ }));
905
+
906
+ (count = await subject.count());
907
+ });
908
+
909
+ after(() => subject.removeAll());
910
+
911
+ it('returns the proper count', () => {
912
+ expect(count).to.equal(3);
913
+ });
914
+ });
915
+
916
+ context('with serviceId and termsType filters', () => {
917
+ before(async () => {
918
+ await subject.save(new Version({
919
+ serviceId: SERVICE_PROVIDER_ID,
920
+ termsType: TERMS_TYPE,
921
+ content: CONTENT,
922
+ fetchDate: FETCH_DATE,
923
+ snapshotIds: [SNAPSHOT_ID],
924
+ }));
925
+ await subject.save(new Version({
926
+ serviceId: SERVICE_PROVIDER_ID,
927
+ termsType: TERMS_TYPE,
928
+ content: `${CONTENT} - updated`,
929
+ fetchDate: FETCH_DATE_LATER,
930
+ snapshotIds: [SNAPSHOT_ID],
931
+ }));
932
+ await subject.save(new Version({
933
+ serviceId: 'other_service',
934
+ termsType: 'Privacy Policy',
935
+ content: 'Other content',
936
+ fetchDate: FETCH_DATE,
937
+ snapshotIds: [SNAPSHOT_ID],
938
+ }));
939
+ });
940
+
941
+ after(() => subject.removeAll());
942
+
943
+ it('returns count for specific service and terms type', async () => {
944
+ const filteredCount = await subject.count(SERVICE_PROVIDER_ID, TERMS_TYPE);
945
+
946
+ expect(filteredCount).to.equal(2);
947
+ });
948
+
949
+ it('returns zero for non-existent service', async () => {
950
+ const filteredCount = await subject.count('non-existent-service', TERMS_TYPE);
951
+
952
+ expect(filteredCount).to.equal(0);
953
+ });
954
+ });
955
+
956
+ context('with only serviceId filter', () => {
957
+ before(async () => {
958
+ await subject.save(new Version({
959
+ serviceId: SERVICE_PROVIDER_ID,
960
+ termsType: TERMS_TYPE,
961
+ content: CONTENT,
962
+ fetchDate: FETCH_DATE,
963
+ snapshotIds: [SNAPSHOT_ID],
964
+ }));
965
+ await subject.save(new Version({
966
+ serviceId: SERVICE_PROVIDER_ID,
967
+ termsType: 'Different Terms',
968
+ content: 'Different content',
969
+ fetchDate: FETCH_DATE_LATER,
970
+ snapshotIds: [SNAPSHOT_ID],
971
+ }));
972
+ await subject.save(new Version({
973
+ serviceId: 'other_service',
974
+ termsType: 'Privacy Policy',
975
+ content: 'Other content',
976
+ fetchDate: FETCH_DATE,
977
+ snapshotIds: [SNAPSHOT_ID],
978
+ }));
979
+ });
980
+
981
+ after(() => subject.removeAll());
982
+
983
+ it('returns count for all terms types of a service', async () => {
984
+ const filteredCount = await subject.count(SERVICE_PROVIDER_ID);
985
+
986
+ expect(filteredCount).to.equal(2);
987
+ });
671
988
  });
672
989
  });
673
990
 
@@ -1197,8 +1514,8 @@ describe('MongoRepository', () => {
1197
1514
  }
1198
1515
  });
1199
1516
 
1200
- it('returns records in ascending order', () => {
1201
- expect(records.map(record => record.fetchDate)).to.deep.equal([ FETCH_DATE_EARLIER, FETCH_DATE, FETCH_DATE_LATER ]);
1517
+ it('returns records in descending order', () => {
1518
+ expect(records.map(record => record.fetchDate)).to.deep.equal([ FETCH_DATE_LATER, FETCH_DATE, FETCH_DATE_EARLIER ]);
1202
1519
  });
1203
1520
  });
1204
1521
 
@@ -60,4 +60,42 @@ describe('Version', () => {
60
60
  });
61
61
  });
62
62
  });
63
+
64
+ describe('#displayTitle', () => {
65
+ const baseParams = {
66
+ serviceId: 'service-A',
67
+ termsType: 'Terms of Service',
68
+ fetchDate: new Date('2000-01-01T12:00:00.000Z'),
69
+ content: 'some content',
70
+ snapshotIds: ['dd263f270b3065e1c18201b49ab898474b357566'],
71
+ };
72
+
73
+ context('when the record is the first one for its service and terms type', () => {
74
+ it('starts with the first-record prefix', () => {
75
+ subject = new Version({ ...baseParams, isFirstRecord: true });
76
+ expect(subject.displayTitle).to.equal('First record of service-A Terms of Service');
77
+ });
78
+ });
79
+
80
+ context('when the record is a technical upgrade', () => {
81
+ it('starts with the technical-upgrade prefix', () => {
82
+ subject = new Version({ ...baseParams, isTechnicalUpgrade: true });
83
+ expect(subject.displayTitle).to.equal('Apply technical or declaration upgrade on service-A Terms of Service');
84
+ });
85
+ });
86
+
87
+ context('when the record is a regular content change', () => {
88
+ it('starts with the update prefix', () => {
89
+ subject = new Version(baseParams);
90
+ expect(subject.displayTitle).to.equal('Record new changes of service-A Terms of Service');
91
+ });
92
+ });
93
+
94
+ context('when the record is both a first record and a technical upgrade', () => {
95
+ it('prioritises the first-record prefix', () => {
96
+ subject = new Version({ ...baseParams, isFirstRecord: true, isTechnicalUpgrade: true });
97
+ expect(subject.displayTitle).to.equal('First record of service-A Terms of Service');
98
+ });
99
+ });
100
+ });
63
101
  });
@@ -281,7 +281,7 @@ function getHistoryFilePaths(serviceId) {
281
281
  }
282
282
 
283
283
  async function loadServiceHistory(historyFilePath) {
284
- if (!(await fileExists(historyFilePath))) return {};
284
+ if (!(await fileExists(historyFilePath))) { return {}; }
285
285
 
286
286
  try {
287
287
  return JSON.parse(await fs.readFile(historyFilePath));
@@ -1,3 +1,7 @@
1
+ import path from 'path';
2
+
3
+ import mime from 'mime';
4
+
1
5
  export default class SourceDocument {
2
6
  /**
3
7
  * Represents a source document containing web content and metadata for extraction.
@@ -21,7 +25,7 @@ export default class SourceDocument {
21
25
  this.filters = filters;
22
26
  this.content = content;
23
27
  this.mimeType = mimeType;
24
- this.id = new URL(location).pathname.split('/').filter(Boolean).join('-');
28
+ this.id = SourceDocument.generateId(location);
25
29
  }
26
30
 
27
31
  get cssSelectors() {
@@ -60,6 +64,20 @@ export default class SourceDocument {
60
64
  return [selector];
61
65
  }
62
66
 
67
+ static generateId(location) {
68
+ const ILLEGAL_CHARACTERS = /[\\:"<>|*?]/g; // Characters forbidden in filenames for cross-platform compatibility; see https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/upload/path-and-artifact-name-validation.ts
69
+
70
+ const pathname = decodeURIComponent(new URL(location).pathname);
71
+ const extension = path.extname(pathname);
72
+ const pathnameWithoutExtension = mime.getType(extension) ? pathname.slice(0, -extension.length) : pathname; // Remove file extension when it corresponds to a known MIME type, as the extension is not part of the document's identity but a web server implementation detail
73
+
74
+ return pathnameWithoutExtension
75
+ .split('/')
76
+ .filter(Boolean)
77
+ .join('-')
78
+ .replace(ILLEGAL_CHARACTERS, '_');
79
+ }
80
+
63
81
  toPersistence() {
64
82
  return {
65
83
  fetch: this.location,
@@ -157,6 +157,63 @@ describe('SourceDocument', () => {
157
157
  });
158
158
  });
159
159
 
160
+ describe('#generateId', () => {
161
+ it('generates ID from URL pathname', () => {
162
+ expect(new SourceDocument({ location: 'https://example.com/legal/terms' }).id).to.equal('legal-terms');
163
+ });
164
+
165
+ it('returns empty string for root URL', () => {
166
+ expect(new SourceDocument({ location: 'https://example.com/' }).id).to.equal('');
167
+ });
168
+
169
+ it('decodes URL-encoded characters', () => {
170
+ expect(new SourceDocument({ location: 'https://example.com/terms%20of%20service' }).id).to.equal('terms of service');
171
+ });
172
+
173
+ it('removes known file extension from URL pathname', () => {
174
+ expect(new SourceDocument({ location: 'https://example.com/en.html' }).id).to.equal('en');
175
+ });
176
+
177
+ it('removes only the last file extension', () => {
178
+ expect(new SourceDocument({ location: 'https://example.com/terms.backup.html' }).id).to.equal('terms.backup');
179
+ });
180
+
181
+ it('keeps unknown extension in URL pathname', () => {
182
+ expect(new SourceDocument({ location: 'https://example.com/terms.of.service' }).id).to.equal('terms.of.service');
183
+ });
184
+ it('decodes URL-encoded characters before replacing illegal ones', () => {
185
+ expect(new SourceDocument({ location: 'https://example.com/terms%3Aof%3Aservice' }).id).to.equal('terms_of_service');
186
+ });
187
+
188
+ context('replaces characters that are illegal in filenames for cross-platform compatibility', () => {
189
+ const ILLEGAL_CHARACTERS_IN_URL_PATHNAME = {
190
+ ':': 'colon',
191
+ '"': 'double quote',
192
+ '<': 'less than',
193
+ '>': 'greater than',
194
+ '|': 'vertical bar',
195
+ '*': 'asterisk',
196
+ };
197
+
198
+ for (const [ character, name ] of Object.entries(ILLEGAL_CHARACTERS_IN_URL_PATHNAME)) {
199
+ it(`replaces ${name} "${character}"`, () => {
200
+ expect(new SourceDocument({ location: `https://example.com/before${character}after` }).id).to.equal('before_after');
201
+ });
202
+ }
203
+
204
+ const ILLEGAL_CHARACTERS_ENCODED_IN_URL = {
205
+ '%5C': 'backslash',
206
+ '%3F': 'question mark',
207
+ };
208
+
209
+ for (const [ encoded, name ] of Object.entries(ILLEGAL_CHARACTERS_ENCODED_IN_URL)) {
210
+ it(`replaces ${name} decoded from "${encoded}"`, () => {
211
+ expect(new SourceDocument({ location: `https://example.com/before${encoded}after` }).id).to.equal('before_after');
212
+ });
213
+ }
214
+ });
215
+ });
216
+
160
217
  describe('#toPersistence', () => {
161
218
  it('converts basic source document declarations into JSON representation', () => {
162
219
  const result = new SourceDocument({