1 package com.atlassian.scheduler.core.tests;
2
3 import com.atlassian.scheduler.cron.CronExpressionValidator;
4 import com.atlassian.scheduler.cron.CronSyntaxException;
5 import com.atlassian.scheduler.cron.ErrorCode;
6 import org.hamcrest.Description;
7 import org.hamcrest.Matcher;
8 import org.hamcrest.TypeSafeMatcher;
9 import org.junit.Test;
10
11 import java.io.PrintWriter;
12 import java.io.StringWriter;
13 import java.util.Locale;
14 import java.util.Objects;
15
16 import static com.atlassian.scheduler.cron.ErrorCode.COMMA_WITH_LAST_DOM;
17 import static com.atlassian.scheduler.cron.ErrorCode.COMMA_WITH_WEEKDAY_DOM;
18 import static com.atlassian.scheduler.cron.ErrorCode.ILLEGAL_CHARACTER;
19 import static com.atlassian.scheduler.cron.ErrorCode.ILLEGAL_CHARACTER_AFTER_INTERVAL;
20 import static com.atlassian.scheduler.cron.ErrorCode.ILLEGAL_CHARACTER_AFTER_QM;
21 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME;
22 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME_DAY_OF_WEEK;
23 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME_FIELD;
24 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME_RANGE;
25 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_DAY_OF_MONTH;
26 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_DAY_OF_WEEK;
27 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_HOUR;
28 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_MONTH;
29 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_SEC_OR_MIN;
30 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_YEAR;
31 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_YEAR_RANGE;
32 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP;
33 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_DAY_OF_MONTH;
34 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_DAY_OF_WEEK;
35 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_HOUR;
36 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_MONTH;
37 import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_SECOND_OR_MINUTE;
38 import static com.atlassian.scheduler.cron.ErrorCode.QM_CANNOT_USE_FOR_BOTH_DAYS;
39 import static com.atlassian.scheduler.cron.ErrorCode.QM_CANNOT_USE_HERE;
40 import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_END_OF_EXPRESSION;
41 import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_TOKEN_FLAG_L;
42 import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_TOKEN_FLAG_W;
43 import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_TOKEN_HASH;
44 import static org.junit.Assert.assertThat;
45 import static org.junit.Assert.fail;
46
47
48
49
50
51
52 public abstract class CronExpressionValidatorTest {
53 protected CronExpressionValidator validator;
54
55 @Test
56 public void testEmptyString() {
57 assertError("", UNEXPECTED_END_OF_EXPRESSION, "Unexpected end of expression", null, 0);
58 }
59
60 @Test
61 public void testMissingFields() {
62 assertError("0 0 0 ? 1", UNEXPECTED_END_OF_EXPRESSION, "Unexpected end of expression", null, 9);
63 }
64
65 @Test
66 public void testTruncatedExpression() {
67 assertError("0 0 0 ? 1 NO", INVALID_NAME_DAY_OF_WEEK, "Invalid day-of-week name: 'NO'", "NO", 10);
68 }
69
70 @Test
71 public void testNameRanges() {
72 assertInvalidNameRange("0 0 0 ? 1 1-FRI", 12);
73 assertInvalidNameRange("0 0 0 ? 1 MON-4", 14);
74 assertInvalidNameRange("0 0 0 ? 1 MON-45", 14);
75 assertInvalidNameRange("0 0 0 ? 1 MON-456", 14);
76 assertInvalidNameRange("0 0 0 ? 1 MON-45F", 14);
77 assertInvalidNameDayOfWeek("0 0 0 ? 1 MON-FFF", "FFF", 14);
78 assertValid("0 0 0 ? 1 MON-FRI");
79 }
80
81 @Test
82 public void testNameRangesWrongLength() {
83 assertInvalidNameDayOfWeek("0 0 0 ? 1 M-FRI", "M", 10);
84 assertInvalidNameDayOfWeek("0 0 0 ? 1 MO-FRI", "MO", 10);
85 assertInvalidNameDayOfWeek("0 0 0 ? 1 MON-F", "F", 14);
86 assertInvalidNameDayOfWeek("0 0 0 ? 1 MON-FR", "FR", 14);
87 assertInvalidName("0 0 0 WL 1 ?", "WL", 6);
88 }
89
90 @Test
91 public void testCronDoesNotGrokThatCharacter() {
92 assertInvalidNameField("0 XXX 0 ? 1 MON-TUE", "XXX", 2);
93 assertErrorQuartzExempt("0 0XY 0 ? 1 MON-TUE", ILLEGAL_CHARACTER, "Unexpected character: 'X'", "X", 3,
94 "the trailing 'XY' just gets ignored");
95 assertErrorQuartzExempt("0 0-3XY 0 ? 1 MON-TUE", ILLEGAL_CHARACTER, "Unexpected character: 'X'", "X", 5,
96 "the trailing 'XY' just gets ignored");
97 }
98
99 @Test
100 public void testQuestionMarkWithNonWhitespaceAfterIt() {
101 assertValid("0 0 0 ? 1 MON-TUE");
102 assertValid("0 0 0 ? 1 Mon-tUe");
103 assertValid("0 0 0 ?\t1 mon-tue");
104 assertErrorQuartzExempt("0 0 0 ?X 1 MON-TUE", ILLEGAL_CHARACTER_AFTER_QM, "Illegal character after '?': 'X'",
105 "X", 7,
106 "it has an off-by-one error in its parser and misses the X.");
107 assertError("0 0 0 ?XY 1 MON-TUE", ILLEGAL_CHARACTER_AFTER_QM, "Illegal character after '?': 'X'", "X", 7);
108 }
109
110 @Test
111 public void testQuestionMarkInNonDayField() {
112 assertError("0 ? 0 ? 1 1", QM_CANNOT_USE_HERE,
113 "You can only use '?' for the day-of-month or the day-of-week.", null, 2);
114 }
115
116 @Test
117 public void testQuestionMarkInBothDayFields() {
118 assertError("0 0 0 ? 1 ?", QM_CANNOT_USE_FOR_BOTH_DAYS,
119 "You cannot specify '?' for both the day-of-month and the day-of-week.", null, 10);
120 }
121
122 @Test
123 public void testMisplacedCommas() {
124 assertError("0 0 0 L,3 1 ?", COMMA_WITH_LAST_DOM,
125 "You cannot use 'L' or 'W' with multiple day-of-month values.", null, 7);
126 assertError("0 0 0 3,LW 1 ?", COMMA_WITH_LAST_DOM,
127 "You cannot use 'L' or 'W' with multiple day-of-month values.", null, 8);
128 assertError("0 0 0 L-4W,7 1 ?", COMMA_WITH_LAST_DOM,
129 "You cannot use 'L' or 'W' with multiple day-of-month values.", null, 10);
130 assertErrorQuartzExempt("0 0 0 11W,17,22-23 1 ?", COMMA_WITH_WEEKDAY_DOM,
131 "You cannot use 'W' with multiple day-of-month values.", null, 9,
132 "it interprets this as '11W'");
133 assertErrorQuartzExempt("0 0 0 13,17W,22-23 1 ?", COMMA_WITH_WEEKDAY_DOM,
134 "You cannot use 'W' with multiple day-of-month values.", null, 11,
135 "it interprets this as '13W'");
136 assertErrorQuartzExempt("0 0 0 13,17-19W,23 1 ?", COMMA_WITH_WEEKDAY_DOM,
137 "You cannot use 'W' with multiple day-of-month values.", null, 14,
138 "it ignores the 'W' flag entirely");
139 }
140
141 @Test
142 public void testSupportForLastMinusNumberForDayOfMonth() {
143 assertValid("0 0 0 L-3 1 ?");
144 assertValid("0 0 0 L-4W 1 ?");
145 }
146
147 @Test
148 public void testBadDayOfMonthExpressions() {
149 assertInvalidFlagL("0 0 0 L- 1 ?", 6);
150 assertInvalidFlagL("0 0 0 L-X 1 ?", 6);
151 assertInvalidFlagL("0 0 0 L-XW 1 ?", 6);
152 assertError("0 0 0 W-2 1 ?", UNEXPECTED_TOKEN_FLAG_W, "The 'W' option was used incorrectly.", null, 6);
153 assertInvalidNameField("0 0 0 XYZ 1 ?", "XYZ", 6);
154 }
155
156 @Test
157 public void testLastFlagWithHyphenW() {
158 assertValid("0 0 0 L-W 1 ?");
159 }
160
161 @Test
162 public void testLastFlagSpecifiedForUnsupportedField() {
163 assertErrorQuartzExempt("L 0 0 1 1 ?", UNEXPECTED_TOKEN_FLAG_L, "The 'L' option was used incorrectly.", null, 0,
164 "the invalid L is ignored, there are no values for seconds, and this never matches.");
165 assertErrorQuartzExempt("0 L 0 1 1 ?", UNEXPECTED_TOKEN_FLAG_L, "The 'L' option was used incorrectly.", null, 2,
166 "the invalid L is ignored, there are no values for minutes, and this never matches.");
167 assertInvalidFlagL("6L 0 0 1 1 ?", 1);
168 }
169
170 @Test
171 public void testWeekdayFlagSpecifiedForUnsupportedField() {
172 assertValid("0 0 0 LW 1 ?");
173 assertValid("0 0 0 15W 1 ?");
174 assertError("6W 0 0 1 1 ?", UNEXPECTED_TOKEN_FLAG_W, "The 'W' option was used incorrectly.", null, 1);
175 }
176
177 @Test
178 public void testHashOption() {
179 assertValid("0 0 0 ? 1 1#3");
180 assertValid("0 0 0 ? 1 MON#3");
181 assertError("6#4 0 0 1 1 ?", UNEXPECTED_TOKEN_HASH, "The '#' option was used incorrectly.", null, 1);
182 }
183
184 @Test
185 public void testTrailingGarbageIgnoredIfYearsArePresent() {
186 assertValid("0 0 0 * 1 ? 2000-2099 Then a comment with spe\u00A9ial chars and *,L+& all of that.");
187 assertError("0 0 0 * 1 ? But not unless the year is given", INVALID_NAME_FIELD,
188 "This field does not support names: 'BUT'", "BUT", 12);
189 }
190
191 @Test
192 public void testNumericValueOutOfRange() {
193 assertError("65 0 0 ? 1 MON", INVALID_NUMBER_SEC_OR_MIN,
194 "The values for seconds and minutes must be from 0 to 59.", null, 0);
195 assertError("0 65 0 ? 1 MON", INVALID_NUMBER_SEC_OR_MIN,
196 "The values for seconds and minutes must be from 0 to 59.", null, 2);
197 assertError("0 0 26 ? 1 MON", INVALID_NUMBER_HOUR,
198 "The values for hours must be from 0 to 23.", null, 4);
199 assertError("0 0 0 33 1 ?", INVALID_NUMBER_DAY_OF_MONTH,
200 "The values for day-of-month must be from 1 to 31.", null, 6);
201 assertError("0 0 0 ? 15 MON", INVALID_NUMBER_MONTH,
202 "The values for month must be from 1 to 12.", null, 8);
203 assertError("0 0 0 ? 1 9", INVALID_NUMBER_DAY_OF_WEEK,
204 "The values for day-of-week must be from 1 to 7.", null, 10);
205 assertErrorQuartzExempt("0 0 0 ? 1 6 42", INVALID_NUMBER_YEAR,
206 "The values for year must be from 1970 to 2299.", null, 12,
207 "it does not check that the years are reasonable during the parse.");
208 assertErrorQuartzExempt("0 0 0 ? 1 6 9999", INVALID_NUMBER_YEAR,
209 "The values for year must be from 1970 to 2299.", null, 12,
210 "it does not check that the years are reasonable during the parse.");
211 assertError("0 0 0 ? 1 6 2036-2016", INVALID_NUMBER_YEAR_RANGE,
212 "Year ranges must specify the earlier year first.", null, 17);
213 }
214
215 @Test
216 public void testInvalidIncrements() {
217 assertError("0 / 0 ? 1 MON", INVALID_STEP,
218 "The step interval character '/' must be followed by a positive integer.", null, 3);
219 assertError("/65 0 0 ? 1 MON", INVALID_STEP_SECOND_OR_MINUTE,
220 "The step interval for second or minute must be less than 60: 65", "65", 1);
221 assertError("0 /65 0 ? 1 MON", INVALID_STEP_SECOND_OR_MINUTE,
222 "The step interval for second or minute must be less than 60: 65", "65", 3);
223 assertError("0 0 /26 ? 1 MON", INVALID_STEP_HOUR,
224 "The step interval for hour must be less than 24: 26", "26", 5);
225 assertError("0 0 0 /36 1 ?", INVALID_STEP_DAY_OF_MONTH,
226 "The step interval for day-of-month must be less than 31: 36", "36", 7);
227 assertError("0 0 0 ? /15 MON", INVALID_STEP_MONTH,
228 "The step interval for month must be less than 12: 15", "15", 9);
229 assertError("0 0 0 ? 1 /9", INVALID_STEP_DAY_OF_WEEK,
230 "The step interval for day-of-week must be less than 7: 9", "9", 11);
231 assertError("0 0 0 1/A 1 ?", INVALID_STEP,
232 "The step interval character '/' must be followed by a positive integer.", null, 8);
233 assertError("0 0 0 1/6A 1 ?", ILLEGAL_CHARACTER_AFTER_INTERVAL,
234 "Illegal character after '/': 'A'", "A", 9);
235 }
236
237
238 @Test
239 public void testRealWorldErrorsFromCloud() {
240
241 assertValid(" \t 0 0 0 ? 1 MON-TUE");
242
243
244 assertErrorQuartzExempt("0 0/120 8-16 ? * MON-FRI", INVALID_STEP_SECOND_OR_MINUTE,
245 "The step interval for second or minute must be less than 60: 120", "120", 4,
246 "it only validates the step interval if the range has an upper bound.");
247 assertErrorQuartzExempt("0 0/2 8-18 ? * MON-FRI *?", ILLEGAL_CHARACTER,
248 "Unexpected character: '?'", "?", 24,
249 "it silently ignores anything after the '*' that isn't a step interval.");
250 assertErrorQuartzExempt("0 40 9 1W,15W 1-12 ? 2010-2050", COMMA_WITH_WEEKDAY_DOM,
251 "You cannot use 'W' with multiple day-of-month values.", null, 9,
252 "it interprets this as '11W'");
253 }
254
255
256 protected void assertValid(final String cronExpression) {
257 try {
258 validator.validate(cronExpression);
259 } catch (CronSyntaxException cse) {
260 final Error e = new AssertionError("Expected '" + cronExpression + "' to be valid, but it threw " + cse);
261 e.initCause(cse);
262 throw e;
263 }
264 }
265
266 protected void assertError(final String cronExpression, final ErrorCode errorCode, final String message,
267 final String value, final int errorOffset) {
268 try {
269 validator.validate(cronExpression);
270 fail("Expected a CronSyntaxException with message=\"" + message + "\" and errorOffset=" + errorOffset);
271 } catch (CronSyntaxException cse) {
272 assertThat(cse, exception(cronExpression.toUpperCase(Locale.US), errorCode, message, value, errorOffset));
273 }
274 }
275
276
277
278
279
280
281
282
283 protected boolean verifyErrorOffset(final int expected, final CronSyntaxException cse) {
284 return expected == cse.getErrorOffset();
285 }
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302 protected void assertErrorQuartzExempt(final String cronExpression, final ErrorCode errorCode, final String message,
303 final String value, final int pos, final String whyQuartzIsWrong) {
304 assertError(cronExpression, errorCode, message, value, pos);
305 }
306
307 protected void assertInvalidFlagL(final String cronExpression, final int errorOffset) {
308 assertError(cronExpression, UNEXPECTED_TOKEN_FLAG_L,
309 "The 'L' option was used incorrectly.",
310 null,
311 errorOffset);
312 }
313
314 protected void assertInvalidName(final String cronExpression, final String name, final int errorOffset) {
315 assertError(cronExpression, INVALID_NAME,
316 "Invalid name: '" + name + '\'',
317 name,
318 errorOffset);
319 }
320
321 protected void assertInvalidNameField(final String cronExpression, final String name, final int errorOffset) {
322 assertError(cronExpression, INVALID_NAME_FIELD,
323 "This field does not support names: '" + name + '\'',
324 name,
325 errorOffset);
326 }
327
328 protected void assertInvalidNameRange(final String cronExpression, final int errorOffset) {
329 assertError(cronExpression, INVALID_NAME_RANGE,
330 "Cannot specify a range using month or day-of-week names unless a valid name is used for both bounds.",
331 null,
332 errorOffset);
333 }
334
335 protected void assertInvalidNameDayOfWeek(final String cronExpression, final String name, final int errorOffset) {
336 assertError(cronExpression, INVALID_NAME_DAY_OF_WEEK,
337 "Invalid day-of-week name: '" + name + '\'',
338 name,
339 errorOffset);
340 }
341
342 protected Matcher<CronSyntaxException> exception(final String cronExpression, final ErrorCode errorCode,
343 final String message, final String value, final int errorOffset) {
344 return new CronSyntaxExceptionMatcher(cronExpression, errorCode, message, value, errorOffset);
345 }
346
347 protected static String stackTrace(Throwable e) {
348 final StringWriter sw = new StringWriter();
349 final PrintWriter pw = new PrintWriter(sw, false);
350 e.printStackTrace(pw);
351 pw.flush();
352 return sw.toString();
353 }
354
355
356 class CronSyntaxExceptionMatcher extends TypeSafeMatcher<CronSyntaxException> {
357 private final String cronExpression;
358 private final ErrorCode errorCode;
359 private final String message;
360 private final String value;
361 private final int errorOffset;
362
363 CronSyntaxExceptionMatcher(String cronExpression, ErrorCode errorCode, String message, String value, int errorOffset) {
364 this.cronExpression = cronExpression;
365 this.errorCode = errorCode;
366 this.message = message;
367 this.value = value;
368 this.errorOffset = errorOffset;
369 }
370
371 @Override
372 protected boolean matchesSafely(CronSyntaxException cse) {
373 return Objects.equals(cronExpression, cse.getCronExpression())
374 && errorCode == cse.getErrorCode()
375 && Objects.equals(message, cse.getMessage())
376 && Objects.equals(value, cse.getValue())
377 && verifyErrorOffset(errorOffset, cse);
378 }
379
380 @Override
381 public void describeTo(Description description) {
382 description.appendText("a CronSyntaxException with:\n\tcronExpression: ").appendText(cronExpression)
383 .appendText("\n\t\terrorCode: ").appendText(errorCode.name())
384 .appendText("\n\t\tmessage: ").appendText(message)
385 .appendText("\n\t\tvalue: ").appendText(value)
386 .appendText("\n\t\terrorOffset: ").appendText(String.valueOf(errorOffset));
387 }
388
389 @Override
390 protected void describeMismatchSafely(CronSyntaxException cse, Description description) {
391 description.appendText("a CronSyntaxException with:\n\tcronExpression: ").appendText(cse.getCronExpression())
392 .appendText("\n\t\terrorCode: ").appendText(cse.getErrorCode().name())
393 .appendText("\n\t\tmessage: ").appendText(cse.getMessage())
394 .appendText("\n\t\tvalue: ").appendText(cse.getValue())
395 .appendText("\n\t\terrorOffset: ").appendText(String.valueOf(cse.getErrorOffset()))
396 .appendText("\n").appendText(stackTrace(cse))
397 .appendText("\n");
398 }
399 }
400 }
401