File size: 7,796 Bytes
eb47743
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
import { describe, it, expect } from 'vitest'
import { parseNaturalSchedule, isCronDue } from '../schedule-parser'

describe('parseNaturalSchedule', () => {
  it('returns null for empty input', () => {
    expect(parseNaturalSchedule('')).toBeNull()
    expect(parseNaturalSchedule('   ')).toBeNull()
  })

  it('passes through valid cron expressions', () => {
    const result = parseNaturalSchedule('0 9 * * *')
    expect(result).not.toBeNull()
    expect(result!.cronExpr).toBe('0 9 * * *')
    expect(result!.humanReadable).toContain('0 9 * * *')
  })

  it('passes through step cron expressions when formatted as pure cron', () => {
    // The CRON_REGEX requires each field to be * or digits/commas/ranges.
    // "*/5 * * * *" has a mixed field so it falls through as null (natural language fallback)
    // Instead test a valid 5-field numeric cron:
    const result = parseNaturalSchedule('5 * * * *')
    expect(result!.cronExpr).toBe('5 * * * *')
  })

  it('parses "hourly"', () => {
    const result = parseNaturalSchedule('hourly')
    expect(result!.cronExpr).toBe('0 * * * *')
    expect(result!.humanReadable).toMatch(/every hour/i)
  })

  it('parses "daily"', () => {
    const result = parseNaturalSchedule('daily')
    expect(result!.cronExpr).toBe('0 9 * * *')
  })

  it('parses "every day"', () => {
    const result = parseNaturalSchedule('every day')
    expect(result!.cronExpr).toBe('0 9 * * *')
  })

  it('parses "weekly"', () => {
    const result = parseNaturalSchedule('weekly')
    expect(result!.cronExpr).toBe('0 9 * * 1')
    expect(result!.humanReadable).toMatch(/monday/i)
  })

  it('parses "every N minutes"', () => {
    expect(parseNaturalSchedule('every 5 minutes')!.cronExpr).toBe('*/5 * * * *')
    expect(parseNaturalSchedule('every 1 minute')!.cronExpr).toBe('*/1 * * * *')
    expect(parseNaturalSchedule('every 30 minutes')!.cronExpr).toBe('*/30 * * * *')
  })

  it('returns null for invalid minute intervals', () => {
    expect(parseNaturalSchedule('every 0 minutes')).toBeNull()
    expect(parseNaturalSchedule('every 60 minutes')).toBeNull()
  })

  it('parses "every N hours"', () => {
    expect(parseNaturalSchedule('every 2 hours')!.cronExpr).toBe('0 */2 * * *')
    expect(parseNaturalSchedule('every 1 hour')!.cronExpr).toBe('0 */1 * * *')
  })

  it('returns null for invalid hour intervals', () => {
    expect(parseNaturalSchedule('every 0 hours')).toBeNull()
    expect(parseNaturalSchedule('every 24 hours')).toBeNull()
  })

  it('parses "daily at TIME"', () => {
    const result = parseNaturalSchedule('daily at 9am')
    expect(result!.cronExpr).toBe('0 9 * * *')
    expect(result!.humanReadable).toMatch(/9.*AM/i)
  })

  it('parses "every morning at TIME"', () => {
    const result = parseNaturalSchedule('every morning at 8am')
    expect(result!.cronExpr).toBe('0 8 * * *')
  })

  it('parses "every evening at TIME"', () => {
    const result = parseNaturalSchedule('every evening at 6pm')
    expect(result!.cronExpr).toBe('0 18 * * *')
  })

  it('parses time with minutes', () => {
    const result = parseNaturalSchedule('daily at 9:30am')
    expect(result!.cronExpr).toBe('30 9 * * *')
    expect(result!.humanReadable).toMatch(/9:30/i)
  })

  it('parses "at TIME every day"', () => {
    const result = parseNaturalSchedule('at 10am every day')
    expect(result!.cronExpr).toBe('0 10 * * *')
  })

  it('parses "weekly on DAYNAME"', () => {
    expect(parseNaturalSchedule('weekly on monday')!.cronExpr).toBe('0 9 * * 1')
    expect(parseNaturalSchedule('weekly on friday')!.cronExpr).toBe('0 9 * * 5')
    expect(parseNaturalSchedule('weekly on sunday')!.cronExpr).toBe('0 9 * * 0')
  })

  it('parses "every DAYNAME"', () => {
    expect(parseNaturalSchedule('every monday')!.cronExpr).toBe('0 9 * * 1')
    expect(parseNaturalSchedule('every saturday')!.cronExpr).toBe('0 9 * * 6')
  })

  it('parses "every DAYNAME at TIME"', () => {
    const result = parseNaturalSchedule('every tuesday at 3pm')
    expect(result!.cronExpr).toBe('0 15 * * 2')
    expect(result!.humanReadable).toMatch(/tuesday/i)
    expect(result!.humanReadable).toMatch(/3.*PM/i)
  })

  it('returns null for unrecognized input', () => {
    expect(parseNaturalSchedule('some random text')).toBeNull()
    expect(parseNaturalSchedule('every foo bar')).toBeNull()
  })

  it('handles abbreviated day names', () => {
    expect(parseNaturalSchedule('every mon')!.cronExpr).toBe('0 9 * * 1')
    expect(parseNaturalSchedule('every fri')!.cronExpr).toBe('0 9 * * 5')
  })

  it('parses pm time correctly (12pm = noon)', () => {
    const result = parseNaturalSchedule('daily at 12pm')
    expect(result!.cronExpr).toBe('0 12 * * *')
  })

  it('parses 12am as midnight', () => {
    const result = parseNaturalSchedule('daily at 12am')
    expect(result!.cronExpr).toBe('0 0 * * *')
  })
})

describe('isCronDue', () => {
  // Build a local-time date for Monday at a specific hour/minute
  // isCronDue uses .getHours()/.getMinutes()/.getDay() which are local time methods
  function makeLocalTime(dayOfWeek: number, hour: number, minute: number, second = 0): number {
    // Find a date that has the right local day of week
    const d = new Date()
    d.setSeconds(second)
    d.setMilliseconds(0)
    d.setMinutes(minute)
    d.setHours(hour)
    // Move to the desired day of week
    const diff = dayOfWeek - d.getDay()
    d.setDate(d.getDate() + diff)
    return d.getTime()
  }

  it('returns true when cron matches and not recently spawned', () => {
    const t = makeLocalTime(1, 9, 0) // Monday 09:00 local
    expect(isCronDue('0 9 * * 1', t, 0)).toBe(true)
  })

  it('returns true for * in all fields', () => {
    const t = makeLocalTime(1, 9, 0)
    expect(isCronDue('* * * * *', t, 0)).toBe(true)
  })

  it('returns false when minute does not match', () => {
    const t = makeLocalTime(1, 9, 5) // Monday 09:05
    expect(isCronDue('0 9 * * 1', t, 0)).toBe(false)
  })

  it('returns false when hour does not match', () => {
    const t = makeLocalTime(1, 10, 0) // Monday 10:00
    expect(isCronDue('0 9 * * 1', t, 0)).toBe(false)
  })

  it('returns false when day of week does not match', () => {
    const t = makeLocalTime(2, 9, 0) // Tuesday 09:00
    expect(isCronDue('0 9 * * 1', t, 0)).toBe(false) // Monday only
  })

  it('returns false if already spawned in same minute', () => {
    const t = makeLocalTime(1, 9, 0, 45) // Monday 09:00:45
    const spawnedJustNow = t - 30000 // 30s ago = 09:00:15, same minute
    expect(isCronDue('0 9 * * 1', t, spawnedJustNow)).toBe(false)
  })

  it('returns true if spawned in a previous minute', () => {
    const t = makeLocalTime(1, 9, 0)
    const spawnedPrevMinute = t - 120000 // 2 min ago, different minute
    expect(isCronDue('0 9 * * 1', t, spawnedPrevMinute)).toBe(true)
  })

  it('handles step expressions', () => {
    const t30 = makeLocalTime(1, 9, 30)
    expect(isCronDue('*/30 * * * *', t30, 0)).toBe(true)
    expect(isCronDue('*/15 * * * *', t30, 0)).toBe(true)
    expect(isCronDue('*/7 * * * *', t30, 0)).toBe(false) // 30 % 7 != 0
  })

  it('returns false for invalid cron expression', () => {
    const t = makeLocalTime(1, 9, 0)
    expect(isCronDue('invalid', t, 0)).toBe(false)
    expect(isCronDue('0 9 * *', t, 0)).toBe(false) // only 4 parts
  })

  it('handles comma-separated values', () => {
    const t9 = makeLocalTime(1, 9, 0)
    const t10 = makeLocalTime(1, 10, 0)
    expect(isCronDue('0 9,10 * * *', t9, 0)).toBe(true)
    expect(isCronDue('0 9,10 * * *', t10, 0)).toBe(true)
  })

  it('handles range expressions', () => {
    const t9 = makeLocalTime(1, 9, 0)
    const t18 = makeLocalTime(1, 18, 0)
    expect(isCronDue('0 9-17 * * *', t9, 0)).toBe(true)
    expect(isCronDue('0 9-17 * * *', t18, 0)).toBe(false)
  })
})