@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,51 @@
1
+ import Run, { RUN_STATUSES } from './index.js';
2
+
3
+ export const FILE_NAME = 'run.json';
4
+
5
+ export function toPersistence(run) {
6
+ return {
7
+ message: formatMessage(run),
8
+ content: `${JSON.stringify(toJSON(run), null, 2)}\n`,
9
+ filePath: FILE_NAME,
10
+ date: run.lastRun.endDate || run.lastRun.startDate,
11
+ };
12
+ }
13
+
14
+ export function toDomain(data) {
15
+ try {
16
+ const run = Object.assign(new Run(), data); // The persisted shape is the in-memory shape, so every field accumulated through the run's lifecycle is restored as is
17
+
18
+ run.validate();
19
+
20
+ return run;
21
+ } catch (error) {
22
+ throw new Error(`Invalid run content: ${error.message}`);
23
+ }
24
+ }
25
+
26
+ export function formatMessage(run) { // The status of the run tells which step of its lifecycle is being committed
27
+ switch (run.lastRun.status) {
28
+ case RUN_STATUSES.inProgress:
29
+ return `Start run ${run.shortRunId}`;
30
+ case RUN_STATUSES.completed:
31
+ return `Complete run ${run.shortRunId} (${run.tracked.ok} ok, ${run.tracked.failed} failed)`;
32
+ case RUN_STATUSES.crashed:
33
+ return `Finalize crashed run ${run.shortRunId}`;
34
+ default:
35
+ throw new Error(`Unknown run status: "${run.lastRun.status}"`);
36
+ }
37
+ }
38
+
39
+ function toJSON(run) {
40
+ return {
41
+ runId: run.runId,
42
+ collectionId: run.collectionId,
43
+ schedule: run.schedule,
44
+ lastRun: run.lastRun,
45
+ declarations: run.declarations,
46
+ tracked: run.tracked,
47
+ coverage: run.coverage,
48
+ transitions: run.transitions,
49
+ transientErrors: run.transientErrors,
50
+ };
51
+ }
@@ -0,0 +1,168 @@
1
+ import { expect } from 'chai';
2
+
3
+ import { FILE_NAME, formatMessage, toDomain, toPersistence } from './dataMapper.js';
4
+
5
+ import Run from './index.js';
6
+
7
+ const VALID_PARAMS = {
8
+ runId: 'ota-run-f47ac10b-58cc-4372-a567-0e02b2c3d479',
9
+ collectionId: 'france',
10
+ schedule: '30 */12 * * *',
11
+ startDate: '2026-04-06T10:30:00Z',
12
+ engineVersion: '12.0.0',
13
+ declarationsCommit: 'abc123def456',
14
+ servicesCount: 1,
15
+ termsCount: 1,
16
+ };
17
+
18
+ describe('run/dataMapper', () => {
19
+ describe('FILE_NAME', () => {
20
+ it('is run.json at the repository root', () => {
21
+ expect(FILE_NAME).to.equal('run.json');
22
+ });
23
+ });
24
+
25
+ describe('#formatMessage', () => {
26
+ it('formats an in-progress run', () => {
27
+ const run = new Run(VALID_PARAMS);
28
+
29
+ expect(formatMessage(run)).to.equal('Start run ota-run-f47ac10b');
30
+ });
31
+
32
+ it('formats a completed run with counts read from run.tracked', () => {
33
+ const run = new Run(VALID_PARAMS);
34
+
35
+ run.markCompleted('2026-04-06T10:42:34Z');
36
+ run.tracked = { ok: 1518, failed: 5 };
37
+
38
+ expect(formatMessage(run)).to.equal('Complete run ota-run-f47ac10b (1518 ok, 5 failed)');
39
+ });
40
+
41
+ it('formats a crashed run', () => {
42
+ const run = new Run(VALID_PARAMS);
43
+
44
+ run.markCrashed('2026-04-06T10:42:34Z');
45
+
46
+ expect(formatMessage(run)).to.equal('Finalize crashed run ota-run-f47ac10b');
47
+ });
48
+
49
+ it('throws for unknown status', () => {
50
+ const run = new Run(VALID_PARAMS);
51
+
52
+ run.lastRun.status = 'nonsense';
53
+
54
+ expect(() => formatMessage(run)).to.throw(/Unknown run status/);
55
+ });
56
+ });
57
+
58
+ describe('#toPersistence', () => {
59
+ context('with an in-progress run', () => {
60
+ let persistence;
61
+
62
+ before(() => {
63
+ persistence = toPersistence(new Run(VALID_PARAMS));
64
+ });
65
+
66
+ it('uses the Start run subject', () => {
67
+ expect(persistence.message).to.equal('Start run ota-run-f47ac10b');
68
+ });
69
+
70
+ it('writes to run.json at the repository root', () => {
71
+ expect(persistence.filePath).to.equal('run.json');
72
+ });
73
+
74
+ it('serialises the run payload structure as pretty JSON terminated by a newline', () => {
75
+ const parsed = JSON.parse(persistence.content);
76
+
77
+ expect(parsed).to.have.all.keys([ 'runId', 'collectionId', 'schedule', 'lastRun', 'declarations', 'tracked', 'coverage', 'transitions', 'transientErrors' ]);
78
+ expect(parsed.runId).to.equal('ota-run-f47ac10b-58cc-4372-a567-0e02b2c3d479');
79
+ expect(persistence.content.endsWith('\n')).to.be.true;
80
+ });
81
+
82
+ it('uses the start date when no end date is set', () => {
83
+ expect(persistence.date).to.equal('2026-04-06T10:30:00Z');
84
+ });
85
+ });
86
+
87
+ context('with a completed run', () => {
88
+ let persistence;
89
+
90
+ before(() => {
91
+ const run = new Run(VALID_PARAMS);
92
+
93
+ run.markCompleted('2026-04-06T10:42:34Z');
94
+ run.tracked = { ok: 1518, failed: 5 };
95
+ persistence = toPersistence(run);
96
+ });
97
+
98
+ it('uses the Complete run subject with counts', () => {
99
+ expect(persistence.message).to.equal('Complete run ota-run-f47ac10b (1518 ok, 5 failed)');
100
+ });
101
+
102
+ it('uses the end date once it is set', () => {
103
+ expect(persistence.date).to.equal('2026-04-06T10:42:34Z');
104
+ });
105
+ });
106
+
107
+ context('with a crashed run', () => {
108
+ it('uses the Finalize crashed run subject', () => {
109
+ const run = new Run(VALID_PARAMS);
110
+
111
+ run.markCrashed('2026-04-06T10:42:34Z');
112
+ expect(toPersistence(run).message).to.equal('Finalize crashed run ota-run-f47ac10b');
113
+ });
114
+ });
115
+ });
116
+
117
+ describe('#toDomain', () => {
118
+ it('rebuilds a Run instance from persisted data', () => {
119
+ const persistence = toPersistence(new Run(VALID_PARAMS));
120
+ const data = JSON.parse(persistence.content);
121
+ const run = toDomain(data);
122
+
123
+ expect(run).to.be.instanceOf(Run);
124
+ expect(run.runId).to.equal(VALID_PARAMS.runId);
125
+ expect(run.lastRun.status).to.equal('in_progress');
126
+ expect(run.shortRunId).to.equal('ota-run-f47ac10b');
127
+ });
128
+
129
+ it('restores lifecycle state (status, endDate, tracked, coverage, transitions, transientErrors)', () => {
130
+ const original = new Run(VALID_PARAMS);
131
+
132
+ original.markCompleted('2026-04-06T10:42:34Z');
133
+ original.tracked = { ok: 1518, failed: 5 };
134
+ original.coverage = { processed: 1523, skipped: [{ serviceId: 'X', termsType: 'Y', reason: 'declaration validation failed' }] };
135
+ original.recordTransition('newFailures', { serviceId: 'A', termsType: 'B' });
136
+ original.transientErrors = 23;
137
+
138
+ const persistence = toPersistence(original);
139
+ const rebuilt = toDomain(JSON.parse(persistence.content));
140
+
141
+ expect(rebuilt.lastRun.endDate).to.equal('2026-04-06T10:42:34Z');
142
+ expect(rebuilt.lastRun.status).to.equal('completed');
143
+ expect(rebuilt.tracked).to.deep.equal({ ok: 1518, failed: 5 });
144
+ expect(rebuilt.coverage).to.deep.equal({ processed: 1523, skipped: [{ serviceId: 'X', termsType: 'Y', reason: 'declaration validation failed' }] });
145
+ expect(rebuilt.transitions.newFailures).to.deep.equal([{ serviceId: 'A', termsType: 'B' }]);
146
+ expect(rebuilt.transientErrors).to.equal(23);
147
+ expect(toPersistence(rebuilt).content).to.equal(persistence.content);
148
+ });
149
+
150
+ context('when persisted data is invalid', () => {
151
+ it('throws an error contextualised with the run domain', () => {
152
+ const persistence = toPersistence(new Run(VALID_PARAMS));
153
+ const data = JSON.parse(persistence.content);
154
+
155
+ data.runId = 'bare-uuid-without-prefix';
156
+ expect(() => toDomain(data)).to.throw(/Invalid run content/);
157
+ });
158
+
159
+ it('throws when a required nested field is missing', () => {
160
+ const persistence = toPersistence(new Run(VALID_PARAMS));
161
+ const data = JSON.parse(persistence.content);
162
+
163
+ delete data.lastRun.startDate;
164
+ expect(() => toDomain(data)).to.throw(/Invalid run content/);
165
+ });
166
+ });
167
+ });
168
+ });
@@ -0,0 +1,115 @@
1
+ import { randomUUID } from 'crypto';
2
+
3
+ export const RUN_STATUSES = Object.freeze({
4
+ inProgress: 'in_progress',
5
+ completed: 'completed',
6
+ crashed: 'crashed',
7
+ });
8
+
9
+ export const RUN_ID_PREFIX = 'ota-run-'; // Disambiguates from Git short SHAs and tags run IDs uniquely across logs and Git trailers
10
+ const RUN_ID_REGEXP = /^ota-run-([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
11
+
12
+ const VALID_STATUSES = Object.freeze(Object.values(RUN_STATUSES));
13
+ const VALID_TRANSITION_TYPES = Object.freeze([ 'newFailures', 'recoveries', 'reasonChanges' ]);
14
+
15
+ export default class Run {
16
+ static generateId() {
17
+ return `${RUN_ID_PREFIX}${randomUUID()}`;
18
+ }
19
+
20
+ constructor({
21
+ runId,
22
+ collectionId,
23
+ schedule,
24
+ startDate,
25
+ engineVersion,
26
+ declarationsCommit,
27
+ servicesCount,
28
+ termsCount,
29
+ } = {}) {
30
+ this.runId = runId;
31
+ this.collectionId = collectionId;
32
+ this.schedule = schedule ?? null;
33
+ this.lastRun = {
34
+ startDate,
35
+ endDate: null,
36
+ engineVersion,
37
+ status: RUN_STATUSES.inProgress,
38
+ };
39
+ this.declarations = {
40
+ commit: declarationsCommit,
41
+ services: servicesCount,
42
+ terms: termsCount,
43
+ };
44
+ this.tracked = { ok: 0, failed: 0 };
45
+ this.coverage = { processed: 0, skipped: [] };
46
+ this.transitions = { newFailures: [], recoveries: [], reasonChanges: [] };
47
+ this.transientErrors = 0;
48
+ }
49
+
50
+ get shortRunId() {
51
+ return `${RUN_ID_PREFIX}${runIdMatch(this.runId)[1]}`; // Keeps the `ota-run-` prefix so the short form remains unmistakable against a Git short SHA
52
+ }
53
+
54
+ markCompleted(endDate) {
55
+ this.lastRun.endDate = endDate;
56
+ this.lastRun.status = RUN_STATUSES.completed;
57
+ }
58
+
59
+ markCrashed(endDate) {
60
+ this.lastRun.endDate = endDate;
61
+ this.lastRun.status = RUN_STATUSES.crashed;
62
+ }
63
+
64
+ addSkipped({ serviceId, termsType, reason }) {
65
+ this.coverage.skipped.push({ serviceId, termsType, reason });
66
+ }
67
+
68
+ recordTransition(type, { serviceId, termsType }) {
69
+ if (!VALID_TRANSITION_TYPES.includes(type)) {
70
+ throw new Error(`${this.constructor.name}: invalid transition type "${type}"; must be one of "${VALID_TRANSITION_TYPES.join('", "')}"`);
71
+ }
72
+
73
+ this.transitions[type].push({ serviceId, termsType });
74
+ }
75
+
76
+ validate() {
77
+ const requiredParams = { // Keyed by constructor param names, as callers provide them, even for the values stored under sub-objects; optional chaining so a missing sub-object reports its field as missing instead of crashing the validation
78
+ runId: this.runId,
79
+ collectionId: this.collectionId,
80
+ startDate: this.lastRun?.startDate,
81
+ engineVersion: this.lastRun?.engineVersion,
82
+ declarationsCommit: this.declarations?.commit,
83
+ };
84
+
85
+ for (const [ requiredParam, value ] of Object.entries(requiredParams)) {
86
+ if (value == null) {
87
+ throw new Error(`${this.constructor.name} is not valid; "${requiredParam}" is missing`);
88
+ }
89
+ }
90
+
91
+ if (!VALID_STATUSES.includes(this.lastRun.status)) {
92
+ throw new Error(`${this.constructor.name} is not valid; "lastRun.status" must be one of "${VALID_STATUSES.join('", "')}", got "${this.lastRun.status}"`);
93
+ }
94
+
95
+ if (!Array.isArray(this.coverage?.skipped)) { // The only accumulated field that crash recovery reads structurally
96
+ throw new Error(`${this.constructor.name} is not valid; "coverage.skipped" must be an array`);
97
+ }
98
+
99
+ try {
100
+ runIdMatch(this.runId); // Single source of truth for the runId format check, reused by shortRunId
101
+ } catch (error) {
102
+ throw new Error(`${this.constructor.name} is not valid; ${error.message}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ function runIdMatch(runId) {
108
+ const match = RUN_ID_REGEXP.exec(runId);
109
+
110
+ if (!match) {
111
+ throw new Error(`Invalid runId "${runId}"; expected format "ota-run-<uuid>"`);
112
+ }
113
+
114
+ return match;
115
+ }
@@ -0,0 +1,221 @@
1
+ import { expect } from 'chai';
2
+
3
+ import Run from './index.js';
4
+
5
+ const VALID_PARAMS = {
6
+ runId: 'ota-run-f47ac10b-58cc-4372-a567-0e02b2c3d479',
7
+ collectionId: 'france',
8
+ schedule: '30 */12 * * *',
9
+ startDate: '2026-04-06T10:30:00Z',
10
+ engineVersion: '12.0.0',
11
+ declarationsCommit: 'abc123def456',
12
+ servicesCount: 523,
13
+ termsCount: 1523,
14
+ };
15
+
16
+ describe('Run', () => {
17
+ let subject;
18
+
19
+ describe('.generateId', () => {
20
+ it('returns an id with the "ota-run-" prefix and a UUID payload', () => {
21
+ const id = Run.generateId();
22
+
23
+ expect(id).to.match(/^ota-run-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
24
+ });
25
+
26
+ it('produces distinct ids on successive calls', () => {
27
+ expect(Run.generateId()).to.not.equal(Run.generateId());
28
+ });
29
+
30
+ it('produces ids accepted by Run.validate', () => {
31
+ const run = new Run({ ...VALID_PARAMS, runId: Run.generateId() });
32
+
33
+ expect(() => run.validate()).to.not.throw();
34
+ });
35
+ });
36
+
37
+ describe('constructor', () => {
38
+ context('with flat params (initial construction)', () => {
39
+ before(() => {
40
+ subject = new Run(VALID_PARAMS);
41
+ });
42
+
43
+ it('exposes runId, collectionId and schedule at the root', () => {
44
+ expect(subject.runId).to.equal('ota-run-f47ac10b-58cc-4372-a567-0e02b2c3d479');
45
+ expect(subject.collectionId).to.equal('france');
46
+ expect(subject.schedule).to.equal('30 */12 * * *');
47
+ });
48
+
49
+ it('initializes lastRun with status "in_progress" and no endDate', () => {
50
+ expect(subject.lastRun).to.deep.equal({
51
+ startDate: '2026-04-06T10:30:00Z',
52
+ endDate: null,
53
+ engineVersion: '12.0.0',
54
+ status: 'in_progress',
55
+ });
56
+ });
57
+
58
+ it('initializes declarations with the commit and counts', () => {
59
+ expect(subject.declarations).to.deep.equal({
60
+ commit: 'abc123def456',
61
+ services: 523,
62
+ terms: 1523,
63
+ });
64
+ });
65
+
66
+ it('initializes tracked counters to zero', () => {
67
+ expect(subject.tracked).to.deep.equal({ ok: 0, failed: 0 });
68
+ });
69
+
70
+ it('initializes coverage with empty skipped array', () => {
71
+ expect(subject.coverage).to.deep.equal({ processed: 0, skipped: [] });
72
+ });
73
+
74
+ it('initializes transitions with empty arrays', () => {
75
+ expect(subject.transitions).to.deep.equal({ newFailures: [], recoveries: [], reasonChanges: [] });
76
+ });
77
+
78
+ it('initializes transientErrors to zero', () => {
79
+ expect(subject.transientErrors).to.equal(0);
80
+ });
81
+
82
+ context('when no schedule is provided', () => {
83
+ it('defaults schedule to null', () => {
84
+ const run = new Run({ ...VALID_PARAMS, schedule: undefined });
85
+
86
+ expect(run.schedule).to.be.null;
87
+ });
88
+ });
89
+ });
90
+ });
91
+
92
+ describe('#shortRunId', () => {
93
+ it('returns the prefixed first segment of the UUID', () => {
94
+ subject = new Run(VALID_PARAMS);
95
+ expect(subject.shortRunId).to.equal('ota-run-f47ac10b');
96
+ });
97
+
98
+ it('throws when the runId does not match the expected format', () => {
99
+ subject = new Run({ ...VALID_PARAMS, runId: 'not-a-valid-id' });
100
+ expect(() => subject.shortRunId).to.throw(/Invalid runId/);
101
+ });
102
+ });
103
+
104
+ describe('#markCompleted', () => {
105
+ it('sets status to "completed" and records the endDate', () => {
106
+ subject = new Run(VALID_PARAMS);
107
+ subject.markCompleted('2026-04-06T10:42:34Z');
108
+ expect(subject.lastRun.status).to.equal('completed');
109
+ expect(subject.lastRun.endDate).to.equal('2026-04-06T10:42:34Z');
110
+ });
111
+ });
112
+
113
+ describe('#markCrashed', () => {
114
+ it('sets status to "crashed" and records the endDate', () => {
115
+ subject = new Run(VALID_PARAMS);
116
+ subject.markCrashed('2026-04-06T10:42:34Z');
117
+ expect(subject.lastRun.status).to.equal('crashed');
118
+ expect(subject.lastRun.endDate).to.equal('2026-04-06T10:42:34Z');
119
+ });
120
+ });
121
+
122
+ describe('#addSkipped', () => {
123
+ it('appends an entry to coverage.skipped', () => {
124
+ subject = new Run(VALID_PARAMS);
125
+ subject.addSkipped({ serviceId: 'Facebook', termsType: 'Terms of Service', reason: 'declaration validation failed' });
126
+ expect(subject.coverage.skipped).to.deep.equal([
127
+ { serviceId: 'Facebook', termsType: 'Terms of Service', reason: 'declaration validation failed' },
128
+ ]);
129
+ });
130
+
131
+ it('accumulates multiple entries in order', () => {
132
+ subject = new Run(VALID_PARAMS);
133
+ subject.addSkipped({ serviceId: 'A', termsType: 'ToS', reason: 'bad A' });
134
+ subject.addSkipped({ serviceId: 'B', termsType: 'PP', reason: 'bad B' });
135
+ expect(subject.coverage.skipped).to.deep.equal([
136
+ { serviceId: 'A', termsType: 'ToS', reason: 'bad A' },
137
+ { serviceId: 'B', termsType: 'PP', reason: 'bad B' },
138
+ ]);
139
+ });
140
+ });
141
+
142
+ describe('#recordTransition', () => {
143
+ [ 'newFailures', 'recoveries', 'reasonChanges' ].forEach(type => {
144
+ context(`with type "${type}"`, () => {
145
+ it('appends to the matching transitions list', () => {
146
+ subject = new Run(VALID_PARAMS);
147
+ subject.recordTransition(type, { serviceId: 'Facebook', termsType: 'Terms of Service' });
148
+ expect(subject.transitions[type]).to.deep.equal([
149
+ { serviceId: 'Facebook', termsType: 'Terms of Service' },
150
+ ]);
151
+ });
152
+ });
153
+ });
154
+
155
+ context('with an unknown type', () => {
156
+ it('throws an error', () => {
157
+ subject = new Run(VALID_PARAMS);
158
+ expect(() => subject.recordTransition('unknown', { serviceId: 'X', termsType: 'Y' })).to.throw(/transition type/);
159
+ });
160
+ });
161
+ });
162
+
163
+ describe('#validate', () => {
164
+ context('when all required params are present', () => {
165
+ it('does not throw', () => {
166
+ subject = new Run(VALID_PARAMS);
167
+ expect(subject.validate.bind(subject)).to.not.throw();
168
+ });
169
+ });
170
+
171
+ [ 'runId', 'collectionId', 'startDate', 'engineVersion', 'declarationsCommit' ].forEach(requiredParam => {
172
+ describe(`"${requiredParam}"`, () => {
173
+ context('when missing', () => {
174
+ it('throws an error', () => {
175
+ subject = new Run({ ...VALID_PARAMS, [requiredParam]: undefined });
176
+ expect(subject.validate.bind(subject)).to.throw(RegExp(requiredParam));
177
+ });
178
+ });
179
+ });
180
+ });
181
+
182
+ context('when lastRun.status is not a valid value', () => {
183
+ it('throws an error', () => {
184
+ subject = new Run(VALID_PARAMS);
185
+ subject.lastRun.status = 'unknown';
186
+ expect(subject.validate.bind(subject)).to.throw(/status/);
187
+ });
188
+ });
189
+
190
+ context('when coverage.skipped is not an array', () => {
191
+ it('throws an error', () => {
192
+ subject = new Run(VALID_PARAMS);
193
+ subject.coverage = { processed: 0, skipped: {} };
194
+ expect(subject.validate.bind(subject)).to.throw(/coverage\.skipped/);
195
+ });
196
+ });
197
+
198
+ context('when runId does not match the expected format', () => {
199
+ it('throws an error', () => {
200
+ subject = new Run({ ...VALID_PARAMS, runId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479' });
201
+ expect(subject.validate.bind(subject)).to.throw(/runId/);
202
+ });
203
+ });
204
+
205
+ context('when lastRun is replaced with null after construction', () => {
206
+ it('throws on the first required field resolved through lastRun', () => {
207
+ subject = new Run(VALID_PARAMS);
208
+ subject.lastRun = null;
209
+ expect(subject.validate.bind(subject)).to.throw(/startDate/);
210
+ });
211
+ });
212
+
213
+ context('when declarations is replaced with null after construction', () => {
214
+ it('throws on the first required field resolved through declarations', () => {
215
+ subject = new Run(VALID_PARAMS);
216
+ subject.declarations = null;
217
+ expect(subject.validate.bind(subject)).to.throw(/declarationsCommit/);
218
+ });
219
+ });
220
+ });
221
+ });