Skip to content

Commit adb1139

Browse files
committed
feat(shared): Additional timeline validations
Introduce four new timeline validation functions with corresponding tests.
1 parent 5b3c9b2 commit adb1139

9 files changed

+342
-0
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { currentMomentMustBeLastWhenNoValidFrom } from './current-moment-must-be-last-when-no-valid-from';
2+
3+
describe('currentMomentMustBeLastWhenNoValidFrom', () => {
4+
it('should return an empty array when there is no input', () => {
5+
const input = null;
6+
7+
const result = currentMomentMustBeLastWhenNoValidFrom(input);
8+
expect(result).toEqual([]);
9+
});
10+
11+
it('should return an empty array when there are no moments', () => {
12+
const input = { moments: [] };
13+
14+
const result = currentMomentMustBeLastWhenNoValidFrom(input);
15+
expect(result).toEqual([]);
16+
});
17+
18+
it('should return an empty array when any valid-from exists', () => {
19+
const input = {
20+
'current-moment': 'moment1',
21+
moments: [
22+
{ 'unique-id': 'moment1', 'valid-from': '2023-01-01' },
23+
{ 'unique-id': 'moment2' }
24+
]
25+
};
26+
27+
const result = currentMomentMustBeLastWhenNoValidFrom(input);
28+
expect(result).toEqual([]);
29+
});
30+
31+
it('should return an empty array when current-moment is the last moment', () => {
32+
const input = {
33+
'current-moment': 'moment2',
34+
moments: [
35+
{ 'unique-id': 'moment1' },
36+
{ 'unique-id': 'moment2' }
37+
]
38+
};
39+
40+
const result = currentMomentMustBeLastWhenNoValidFrom(input);
41+
expect(result).toEqual([]);
42+
});
43+
44+
it('should return a message when current-moment is not the last moment', () => {
45+
const input = {
46+
'current-moment': 'moment1',
47+
moments: [
48+
{ 'unique-id': 'moment1' },
49+
{ 'unique-id': 'moment2' }
50+
]
51+
};
52+
53+
const result = currentMomentMustBeLastWhenNoValidFrom(input);
54+
expect(result.length).toBe(1);
55+
expect(result[0].message).toBe('Current-moment "moment1" should be the last moment when no valid-from values are defined.');
56+
});
57+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Checks that current-moment is the last moment when no valid-from values exist.
3+
*/
4+
export function currentMomentMustBeLastWhenNoValidFrom(input) {
5+
if (!input) {
6+
return [];
7+
}
8+
9+
const moments = input.moments;
10+
if (!Array.isArray(moments) || moments.length === 0) {
11+
return [];
12+
}
13+
14+
const currentMomentId = input['current-moment'];
15+
if (!currentMomentId) {
16+
return [];
17+
}
18+
19+
const hasValidFrom = moments.some((moment) => Boolean(moment && moment['valid-from']));
20+
if (hasValidFrom) {
21+
return [];
22+
}
23+
24+
const lastMoment = moments[moments.length - 1];
25+
const lastMomentId = lastMoment ? lastMoment['unique-id'] : undefined;
26+
if (!lastMomentId) {
27+
return [];
28+
}
29+
30+
if (currentMomentId !== lastMomentId) {
31+
return [{
32+
message: `Current-moment "${currentMomentId}" should be the last moment when no valid-from values are defined.`,
33+
path: ['current-moment']
34+
}];
35+
}
36+
37+
return [];
38+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { currentMomentRequiredWhenMomentsNonEmpty } from './current-moment-required-when-moments-non-empty';
2+
3+
describe('currentMomentRequiredWhenMomentsNonEmpty', () => {
4+
it('should return an empty array when there is no input', () => {
5+
const input = null;
6+
7+
const result = currentMomentRequiredWhenMomentsNonEmpty(input);
8+
expect(result).toEqual([]);
9+
});
10+
11+
it('should return an empty array when moments is empty', () => {
12+
const input = { moments: [] };
13+
14+
const result = currentMomentRequiredWhenMomentsNonEmpty(input);
15+
expect(result).toEqual([]);
16+
});
17+
18+
it('should return an empty array when current-moment is provided', () => {
19+
const input = {
20+
'current-moment': 'moment2',
21+
moments: [
22+
{ 'unique-id': 'moment1' },
23+
{ 'unique-id': 'moment2' }
24+
]
25+
};
26+
27+
const result = currentMomentRequiredWhenMomentsNonEmpty(input);
28+
expect(result).toEqual([]);
29+
});
30+
31+
it('should return a message when moments exist but current-moment is missing', () => {
32+
const input = {
33+
moments: [
34+
{ 'unique-id': 'moment1' }
35+
]
36+
};
37+
38+
const result = currentMomentRequiredWhenMomentsNonEmpty(input);
39+
expect(result.length).toBe(1);
40+
expect(result[0].message).toBe('Timeline has moments but no current-moment is set.');
41+
});
42+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Checks that a current-moment is defined when the moments array is non-empty.
3+
*/
4+
export function currentMomentRequiredWhenMomentsNonEmpty(input) {
5+
if (!input) {
6+
return [];
7+
}
8+
9+
const moments = input.moments;
10+
if (!Array.isArray(moments) || moments.length === 0) {
11+
return [];
12+
}
13+
14+
const currentMoment = input['current-moment'];
15+
if (!currentMoment) {
16+
return [{
17+
message: 'Timeline has moments but no current-moment is set.',
18+
path: ['current-moment']
19+
}];
20+
}
21+
22+
return [];
23+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { momentsMustBeNonEmpty } from './moments-must-be-non-empty';
2+
3+
describe('momentsMustBeNonEmpty', () => {
4+
it('should return an empty array when there is no input', () => {
5+
const input = null;
6+
7+
const result = momentsMustBeNonEmpty(input);
8+
expect(result).toEqual([]);
9+
});
10+
11+
it('should return a message when moments is missing', () => {
12+
const input = {};
13+
14+
const result = momentsMustBeNonEmpty(input);
15+
expect(result.length).toBe(1);
16+
expect(result[0].message).toBe('Timeline must define at least one moment.');
17+
});
18+
19+
it('should return a message when moments is empty', () => {
20+
const input = { moments: [] };
21+
22+
const result = momentsMustBeNonEmpty(input);
23+
expect(result.length).toBe(1);
24+
expect(result[0].message).toBe('Timeline must define at least one moment.');
25+
});
26+
27+
it('should return an empty array when moments is non-empty', () => {
28+
const input = { moments: [{ 'unique-id': 'moment1' }] };
29+
30+
const result = momentsMustBeNonEmpty(input);
31+
expect(result).toEqual([]);
32+
});
33+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Checks that the moments array exists and is non-empty.
3+
*/
4+
export function momentsMustBeNonEmpty(input) {
5+
if (!input) {
6+
return [];
7+
}
8+
9+
const moments = input.moments;
10+
if (!Array.isArray(moments) || moments.length === 0) {
11+
return [{
12+
message: 'Timeline must define at least one moment.',
13+
path: ['moments']
14+
}];
15+
}
16+
17+
return [];
18+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { momentsSortedByValidFrom } from './moments-sorted-by-valid-from';
2+
3+
describe('momentsSortedByValidFrom', () => {
4+
it('should return an empty array when there is no input', () => {
5+
const input = null;
6+
7+
const result = momentsSortedByValidFrom(input);
8+
expect(result).toEqual([]);
9+
});
10+
11+
it('should return an empty array when moments are missing', () => {
12+
const input = {};
13+
14+
const result = momentsSortedByValidFrom(input);
15+
expect(result).toEqual([]);
16+
});
17+
18+
it('should return an empty array when valid-froms are sorted', () => {
19+
const input = {
20+
moments: [
21+
{ 'unique-id': 'moment1', 'valid-from': '2023-01-01' },
22+
{ 'unique-id': 'moment2', 'valid-from': '2024-01-01' },
23+
{ 'unique-id': 'moment3' }
24+
]
25+
};
26+
27+
const result = momentsSortedByValidFrom(input);
28+
expect(result).toEqual([]);
29+
});
30+
31+
it('should return a message when valid-froms are out of order', () => {
32+
const input = {
33+
moments: [
34+
{ 'unique-id': 'moment1', 'valid-from': '2024-01-01' },
35+
{ 'unique-id': 'moment2', 'valid-from': '2023-01-01' }
36+
]
37+
};
38+
39+
const result = momentsSortedByValidFrom(input);
40+
expect(result.length).toBe(1);
41+
expect(result[0].message).toBe('Moment with unique-id "moment2" has valid-from "2023-01-01" which is before the previous valid-from "2024-01-01".');
42+
});
43+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Checks that moments with valid-from are ordered by date.
3+
*/
4+
export function momentsSortedByValidFrom(input) {
5+
if (!input) {
6+
return [];
7+
}
8+
9+
const moments = input.moments;
10+
if (!Array.isArray(moments) || moments.length < 2) {
11+
return [];
12+
}
13+
14+
const results = [];
15+
let lastValidFromTimestamp: number | null = null;
16+
let lastValidFromValue: string | null = null;
17+
18+
for (let index = 0; index < moments.length; index += 1) {
19+
const moment = moments[index];
20+
const validFrom = moment ? moment['valid-from'] : undefined;
21+
if (!validFrom) {
22+
continue;
23+
}
24+
25+
const timestamp = Date.parse(validFrom);
26+
if (Number.isNaN(timestamp)) {
27+
continue;
28+
}
29+
30+
if (lastValidFromTimestamp !== null && timestamp < lastValidFromTimestamp) {
31+
const momentId = moment ? moment['unique-id'] : undefined;
32+
const momentLabel = momentId ? `unique-id "${momentId}"` : `index ${index}`;
33+
results.push({
34+
message: `Moment with ${momentLabel} has valid-from "${validFrom}" which is before the previous valid-from "${lastValidFromValue}".`,
35+
path: ['moments', index, 'valid-from']
36+
});
37+
}
38+
39+
lastValidFromTimestamp = timestamp;
40+
lastValidFromValue = validFrom;
41+
}
42+
43+
return results;
44+
}

shared/src/spectral/rules-timeline.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { RulesetDefinition } from '@stoplight/spectral-core';
22
import { truthy } from '@stoplight/spectral-functions';
3+
import { currentMomentMustBeLastWhenNoValidFrom } from './functions/timeline/current-moment-must-be-last-when-no-valid-from';
4+
import { currentMomentRequiredWhenMomentsNonEmpty } from './functions/timeline/current-moment-required-when-moments-non-empty';
35
import { idsAreUnique } from './functions/timeline/ids-are-unique';
46
import { momentIdExists } from './functions/timeline/moment-id-exists';
7+
import { momentsMustBeNonEmpty } from './functions/timeline/moments-must-be-non-empty';
8+
import { momentsSortedByValidFrom } from './functions/timeline/moments-sorted-by-valid-from';
59
import { validFromNotAfterCurrentMoment } from './functions/timeline/valid-from-not-after-current-moment';
610

711
const timelineRules: RulesetDefinition = {
@@ -44,6 +48,46 @@ const timelineRules: RulesetDefinition = {
4448
then: {
4549
function: validFromNotAfterCurrentMoment
4650
}
51+
},
52+
53+
'current-moment-required-when-moments-non-empty': {
54+
description: 'Current-moment must be defined when moments are present.',
55+
severity: 'warn',
56+
message: '{{error}}',
57+
given: '$',
58+
then: {
59+
function: currentMomentRequiredWhenMomentsNonEmpty
60+
}
61+
},
62+
63+
'current-moment-must-be-last-when-no-valid-from': {
64+
description: 'Current-moment must be the last moment when no valid-from values exist.',
65+
severity: 'warn',
66+
message: '{{error}}',
67+
given: '$',
68+
then: {
69+
function: currentMomentMustBeLastWhenNoValidFrom
70+
}
71+
},
72+
73+
'timeline-moments-must-be-non-empty': {
74+
description: 'Timeline must define at least one moment.',
75+
severity: 'warn',
76+
message: '{{error}}',
77+
given: '$',
78+
then: {
79+
function: momentsMustBeNonEmpty
80+
}
81+
},
82+
83+
'moments-must-be-sorted-by-valid-from': {
84+
description: 'Moments with valid-from must be sorted by date.',
85+
severity: 'warn',
86+
message: '{{error}}',
87+
given: '$',
88+
then: {
89+
function: momentsSortedByValidFrom
90+
}
4791
}
4892
}
4993
};

0 commit comments

Comments
 (0)