laravel-workers 0.4.0 → 0.4.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.
@@ -1,474 +1,474 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { createMailer } from '../mail.mjs';
4
- import { createQueue } from '../queue.mjs';
5
- import { createCache } from '../cache.mjs';
6
- import { createStorage } from '../storage.mjs';
7
- import { defineSchedule } from '../scheduler.mjs';
8
-
9
- // mail — log
10
- test('mail log transport', async () => {
11
- const mail = createMailer({ transport: 'log', from: 'x@example.com' });
12
- const r = await mail.send({ to: 'a@b.co', subject: 'hi', html: '<p>hi</p>' });
13
- assert.ok(r.id.startsWith('log-'));
14
- assert.equal(mail.outbox[0].to, 'a@b.co');
15
- });
16
- test('mail noop', async () => {
17
- const mail = createMailer({ transport: 'noop' });
18
- const r = await mail.send({ to: 'a@b.co', subject: 'hi' });
19
- assert.equal(r.id, 'noop');
20
- });
21
- test('mail resend mock fetch', async () => {
22
- let lastUrl = null;
23
- let lastOpts = null;
24
- const fetchImpl = async (url, opts) => { lastUrl = url; lastOpts = opts; return { ok: true, async json(){ return { id: 're_123' }; } }; };
25
- const mail = createMailer({ transport: 'resend', from: 'from@example.com', key: 're_abc', fetchImpl });
26
- const r = await mail.send({ to: 'a@b.co', subject: 'hi', html: 'x' });
27
- assert.equal(r.id, 're_123');
28
- assert.equal(lastUrl, 'https://api.resend.com/emails');
29
- assert.equal(lastOpts.headers['Authorization'], 'Bearer re_abc');
30
- assert.ok(lastOpts.headers['User-Agent']);
31
- const body = JSON.parse(lastOpts.body);
32
- assert.equal(body.from, 'from@example.com');
33
- assert.deepEqual(body.to, ['a@b.co']);
34
- assert.equal(body.cc, undefined);
35
- });
36
- test('mail resend error', async () => {
37
- const fetchImpl = async () => ({ ok: false, status: 401, async text(){ return 'bad key'; } });
38
- const mail = createMailer({ transport: 'resend', fetchImpl });
39
- await assert.rejects(() => mail.send({ to: 'a@b.co', subject: 'hi' }));
40
- });
41
-
42
- // queue — memory
43
- test('queue memory push/pop', async () => {
44
- const q = createQueue({});
45
- await q.push('emails', { to: 'a' });
46
- await q.push('emails', { to: 'b' });
47
- const a = await q.pop('emails');
48
- assert.equal(a.payload.to, 'a');
49
- const b = await q.pop('emails');
50
- assert.equal(b.payload.to, 'b');
51
- assert.equal(await q.pop('emails'), null);
52
- });
53
- test('queue binding send', async () => {
54
- const sent = [];
55
- const binding = { async send(m){ sent.push(m); } };
56
- const q = createQueue({ binding });
57
- const j = await q.push('emails', { to: 'a' });
58
- assert.equal(sent[0].queue, 'emails');
59
- assert.equal(j.payload.to, 'a');
60
- assert.equal(await q.pop(), null);
61
- });
62
-
63
- // cache — memory
64
- test('cache memory get/put/forget', async () => {
65
- const c = createCache({});
66
- assert.equal(await c.get('k'), null);
67
- await c.put('k', { v: 1 }, 10000);
68
- assert.deepEqual(await c.get('k'), { v: 1 });
69
- await c.forget('k');
70
- assert.equal(await c.get('k'), null);
71
- });
72
- test('cache remember', async () => {
73
- const c = createCache({});
74
- let n = 0;
75
- const v1 = await c.remember('k', 10000, async () => ++n);
76
- assert.equal(v1, 1);
77
- const v2 = await c.remember('k', 10000, async () => ++n);
78
- assert.equal(v2, 1);
79
- assert.equal(n, 1);
80
- });
81
- test('cache ttl expiry', async () => {
82
- const c = createCache({});
83
- await c.put('k', 1, 5);
84
- await new Promise(r => setTimeout(r, 10));
85
- assert.equal(await c.get('k'), null);
86
- });
87
- test('cache kv binding', async () => {
88
- const store = new Map();
89
- const kv = { async get(k){ const v=store.get(k); return v===undefined?null:v; }, async put(k,v){ store.set(k,v); }, async delete(k){ store.delete(k); } };
90
- const c = createCache({ kv });
91
- await c.put('k', { x: 1 });
92
- assert.deepEqual(await c.get('k'), { x: 1 });
93
- await c.forget('k');
94
- assert.equal(await c.get('k'), null);
95
- });
96
-
97
- // The `new` expression: the built-in exceptions become errors with the same
98
- // class name and message (Laravel answers a 500 for them too).
99
- test('the runtime: rt.newError keeps the PHP class name and message', async () => {
100
- const { buildRt } = await import('../../compiler/runtime/rt-from-context.mjs');
101
- const rt = buildRt(null, { runtime: true });
102
- const e = rt.newError('RuntimeException', 'boom');
103
- assert.equal(e.name, 'RuntimeException');
104
- assert.equal(e.message, 'boom');
105
- assert.equal(e.phpClass, 'RuntimeException');
106
- assert.equal(rt.throwIf(false, e), null);
107
- assert.throws(() => rt.throwIf(true, e), /boom/);
108
- assert.equal(rt.throwUnless(true, e), null);
109
- assert.throws(() => rt.throwUnless(false, e), /boom/);
110
- });
111
-
112
- // The flash: the redirect's with()/withInput()/withErrors() write into the
113
- // session (Laravel reads the flash on the next request).
114
- test('the runtime: the redirect flashes into the session', async () => {
115
- const { buildRt } = await import('../../compiler/runtime/rt-from-context.mjs');
116
- const flashed = [];
117
- const inputFlashed = [];
118
- const session = {
119
- flash(key, value) { flashed.push({ key, value }); },
120
- flashInput(input) { inputFlashed.push(input); },
121
- token() { return 't'; },
122
- };
123
- const rt = buildRt(null, {
124
- runtime: true,
125
- http: {
126
- session,
127
- request: { all: () => ({ title: 'Flash me', password: 'secret' }) },
128
- },
129
- });
130
- const res = rt.redirect('/x').with('status', 'saved');
131
- assert.equal(res.status, 302);
132
- assert.equal(res.headers.location, '/x');
133
- assert.deepEqual(flashed, [{ key: 'status', value: 'saved' }]);
134
- rt.redirect('/x').with({ a: 1, b: 2 });
135
- assert.deepEqual(flashed.slice(1), [{ key: 'a', value: 1 }, { key: 'b', value: 2 }]);
136
- rt.redirect('/x').withInput();
137
- assert.deepEqual(inputFlashed, [{ title: 'Flash me' }]);
138
- });
139
-
140
- // The helper surface: back(), data_get(), tap(), report() and env().
141
- test('the runtime: the helper surface (back/dataGet/tap/report/env)', async () => {
142
- const { buildRt } = await import('../../compiler/runtime/rt-from-context.mjs');
143
- const logged = [];
144
- const rt = buildRt(null, {
145
- runtime: true,
146
- http: {
147
- session: { previousUrl: () => '/prev' },
148
- request: { header: (h) => (h === 'referer' ? '/ref' : null) },
149
- log: (level, message) => { logged.push({ level, message }); return null; },
150
- config: { 'app.env': 'local' },
151
- },
152
- });
153
- assert.equal(rt.back().headers.location, '/prev');
154
- assert.equal(rt.back('/fallback').headers.location, '/prev');
155
- assert.equal(rt.dataGet({ a: { b: 2 } }, 'a.b'), 2);
156
- assert.equal(rt.dataGet({ a: [1, 2] }, 'a.1'), 2);
157
- assert.equal(rt.dataGet({ a: 1 }, 'x', 'def'), 'def');
158
- assert.equal(rt.tap('v', () => {}), 'v');
159
- rt.report(new Error('boom'));
160
- assert.deepEqual(logged, [{ level: 'error', message: 'boom' }]);
161
- assert.equal(rt.env('APP_ENV'), 'local');
162
- assert.throws(() => rt.env('NOPE'), /no compiled counterpart/);
163
- });
164
-
165
- // The helper verbs: the request clock (now()/today()) and the log sink.
166
- test('the runtime: now()/today() follow the request clock and rt.log reaches the sink', async () => {
167
- const { buildRt } = await import('../../compiler/runtime/rt-from-context.mjs');
168
- const logged = [];
169
- const rt = buildRt(null, {
170
- runtime: true,
171
- http: {
172
- clock: () => 1767225600, // 2026-01-01T00:00:00Z (the shadow freeze)
173
- log: (level, message) => { logged.push({ level, message }); return null; },
174
- },
175
- });
176
- assert.equal(rt.now().toDateTimeString(), '2026-01-01 00:00:00');
177
- assert.equal(rt.today().toDateString(), '2026-01-01');
178
- assert.equal(rt.abortIf(false, 404, 'never'), null);
179
- assert.throws(() => rt.abortIf(true, 404, 'gone'), (e) => e.status === 404);
180
- assert.equal(rt.abortUnless(true, 404, 'never'), null);
181
- assert.throws(() => rt.abortUnless(false, 500, 'gone'), (e) => e.status === 500);
182
- assert.equal(rt.log('info', 'hello'), null);
183
- assert.deepEqual(logged, [{ level: 'info', message: 'hello' }]);
184
- });
185
-
186
- // storage — memory
187
- test('storage memory put/get/delete', async () => {
188
- const s = createStorage({});
189
- // Laravel's FilesystemAdapter::put() answers a bool, not the path.
190
- assert.equal(await s.put('a/b.txt', 'hello'), true);
191
- const obj = await s.get('a/b.txt');
192
- assert.equal(obj.body, 'hello');
193
- assert.equal(s.url('a/b.txt'), '/r2/a/b.txt');
194
- assert.equal(await s.delete('a/b.txt'), true);
195
- assert.equal(await s.get('a/b.txt'), null);
196
- // Laravel answers false when the file was missing.
197
- assert.equal(await s.delete('a/b.txt'), false);
198
- });
199
- test('storage: a missing file fails loud on get (Laravel throws UnableToReadFile)', async () => {
200
- const s = createStorage({});
201
- await assert.rejects(() => s.getText('nope.txt'), /does not exist/);
202
- });
203
- test('storage r2 binding', async () => {
204
- const m = new Map();
205
- const r2 = {
206
- async put(k,v){ m.set(k,v); },
207
- async get(k){ const v=m.get(k); return v===undefined?null:{ body: v }; },
208
- async head(k){ return m.has(k) ? { body: 'x' } : null; },
209
- async delete(k){ m.delete(k); },
210
- };
211
- const s = createStorage({ r2 });
212
- assert.equal(await s.put('x', 'v'), true);
213
- assert.equal((await s.get('x')).body, 'v');
214
- assert.equal(await s.delete('x'), true);
215
- assert.equal(await s.delete('x'), false);
216
- });
217
-
218
- // storage — the D1 blob store (one row per object; no R2, no card)
219
- function mockD1() {
220
- const rows = new Map();
221
- const state = { tables: new Set(), created: 0, statements: [] };
222
- return {
223
- state,
224
- prepare(sql) {
225
- state.statements.push(sql);
226
- const stmt = {
227
- params: [],
228
- bind(...p) { this.params = p; return this; },
229
- async first() {
230
- if (sql.includes('lw_blobs') && !state.tables.has('lw_blobs')) {
231
- throw new Error('D1_ERROR: no such table: lw_blobs');
232
- }
233
- if (/SELECT 1/.test(sql)) return { '1': 1 };
234
- const row = rows.get(this.params[0]);
235
- return row === undefined ? null : { body: row.body, size: row.size };
236
- },
237
- async run() {
238
- if (/^CREATE TABLE/.test(sql)) {
239
- state.tables.add('lw_blobs');
240
- state.created++;
241
- return { success: true };
242
- }
243
- if (/^INSERT OR REPLACE/.test(sql)) {
244
- const [key, body, size] = this.params;
245
- rows.set(key, { body, size });
246
- return { success: true, meta: { changes: 1 } };
247
- }
248
- if (/^DELETE/.test(sql)) {
249
- const existed = rows.has(this.params[0]);
250
- rows.delete(this.params[0]);
251
- return { success: true, meta: { changes: existed ? 1 : 0 } };
252
- }
253
- return { success: true };
254
- },
255
- async all() { return { results: [] }; },
256
- };
257
- return stmt;
258
- },
259
- };
260
- }
261
-
262
- test('storage d1 blob store: put/get/exists/delete (one row per object)', async () => {
263
- const d1 = mockD1();
264
- const s = createStorage({ d1 });
265
- assert.equal(s.kind, 'd1');
266
- assert.equal(await s.put('a/b.txt', 'hello'), true);
267
- assert.equal(await s.getText('a/b.txt'), 'hello');
268
- assert.equal(await s.exists('a/b.txt'), true);
269
- assert.equal(s.url('a/b.txt'), '/storage/a/b.txt');
270
- assert.equal(await s.delete('a/b.txt'), true);
271
- assert.equal(await s.exists('a/b.txt'), false);
272
- // Laravel: false when the file was missing; a missing file is not null on get.
273
- assert.equal(await s.delete('a/b.txt'), false);
274
- await assert.rejects(() => s.getText('a/b.txt'), /does not exist/);
275
- });
276
-
277
- test('storage d1 blob store: the table is created once per binding, not per request', async () => {
278
- const d1 = mockD1();
279
- const s = createStorage({ d1 });
280
- await s.put('k', 'v');
281
- await s.get('k');
282
- await s.exists('k');
283
- assert.equal(d1.state.created, 1, 'the CREATE TABLE ran more than once');
284
- assert.ok(d1.state.tables.has('lw_blobs'));
285
- });
286
-
287
- test('storage d1 blob store: an object over the D1 row limit fails loud', async () => {
288
- const d1 = mockD1();
289
- const s = createStorage({ d1 });
290
- await assert.rejects(() => s.put('big', 'x'.repeat(1500001)), /at most 1500000 bytes/);
291
- });
292
-
293
- test('storage: a binary object (an uploaded file) round-trips', async () => {
294
- // The D1 blob store: the row is TEXT, so the bytes are base64-encoded.
295
- const d1 = mockD1();
296
- const s = createStorage({ d1 });
297
- const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 65, 66, 67]);
298
- assert.equal(await s.put('a/b.bin', bytes), true);
299
- const read = await s.get('a/b.bin');
300
- assert.ok(read.body instanceof Uint8Array, 'the binary object comes back as bytes');
301
- assert.deepEqual(Array.from(read.body), Array.from(bytes));
302
- // A text object stays text (no marker).
303
- await s.put('a/c.txt', 'hello');
304
- const text = await s.get('a/c.txt');
305
- assert.equal(text.body, 'hello');
306
- // The R2 path: the bytes as they are.
307
- const m = new Map();
308
- const r2 = {
309
- async put(k, v) { m.set(k, v); },
310
- async get(k) { const v = m.get(k); return v === undefined ? null : { body: v }; },
311
- async delete(k) { m.delete(k); },
312
- };
313
- const s2 = createStorage({ r2 });
314
- await s2.put('a/b.bin', bytes);
315
- const read2 = await s2.get('a/b.bin');
316
- assert.deepEqual(Array.from(read2.body), Array.from(bytes));
317
- });
318
-
319
- test('storage: r2 wins over d1 when the app binds both', async () => {
320
- const m = new Map();
321
- const r2 = { async put(k,v){ m.set(k,v); }, async get(k){ const v=m.get(k); return v===undefined?null:{ body: v }; }, async delete(k){ m.delete(k); } };
322
- const s = createStorage({ r2, d1: mockD1() });
323
- assert.equal(s.kind, 'r2');
324
- assert.equal(s.url('x'), '/r2/x');
325
- });
326
-
327
- // scheduler — cron
328
- test('the dumped schedule: dueEvents picks the events that fire now', async () => {
329
- const { dueEvents, cronMatches } = await import('../scheduler.mjs');
330
- const events = [
331
- { name: 'posts:prune', expression: '*/5 * * * *', command: 'posts:prune', timezone: 'UTC' },
332
- { name: 'inspire', expression: '0 9 * * *', command: 'inspire', timezone: 'UTC' },
333
- { name: 'broken', expression: '', command: 'x' },
334
- ];
335
- assert.equal(cronMatches('*/5 * * * *', new Date('2026-10-18T12:15:00Z')), true);
336
- assert.equal(cronMatches('*/5 * * * *', new Date('2026-10-18T12:16:00Z')), false);
337
- const due = dueEvents(events, new Date('2026-10-18T12:15:00Z'));
338
- assert.equal(due.length, 1);
339
- assert.equal(due[0].name, 'posts:prune');
340
- // An event without an expression is never due (and never throws).
341
- assert.equal(dueEvents([{ name: 'x' }], new Date('2026-10-18T12:15:00Z')).length, 0);
342
- // A broken expression is skipped, not fatal.
343
- assert.equal(dueEvents([{ name: 'y', expression: 'not a cron' }], new Date('2026-10-18T12:15:00Z')).length, 0);
344
- });
345
- test('scheduler every minute', async () => {
346
- const s = defineSchedule(api => { api.call('a', '* * * * *', async()=>{}); });
347
- const d = new Date('2026-10-18T12:34:00Z');
348
- assert.equal(s._match(d, '* * * * *'), true);
349
- const r = s.run(d);
350
- assert.equal(r.due.length, 1);
351
- });
352
- test('scheduler minute step', async () => {
353
- const s = defineSchedule(api => { api.call('a', '*/15 * * * *', async()=>{}); });
354
- assert.equal(s._match(new Date('2026-10-18T12:15:00Z'), '*/15 * * * *'), true);
355
- assert.equal(s._match(new Date('2026-10-18T12:16:00Z'), '*/15 * * * *'), false);
356
- });
357
- test('scheduler hour and dow', async () => {
358
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T09:00:00Z'), '0 9 * * *'), true);
359
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T09:00:00Z'), '0 10 * * *'), false);
360
- // 2026-10-18 is Sunday (0)
361
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T00:00:00Z'), '* * * * 0'), true);
362
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T00:00:00Z'), '* * * * 7'), true);
363
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T00:00:00Z'), '* * * * 1'), false);
364
- });
365
- test('scheduler list and range', async () => {
366
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T12:01:00Z'), '0,15,30 * * * *'), false);
367
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T12:15:00Z'), '0,15,30 * * * *'), true);
368
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T12:07:00Z'), '5-10 * * * *'), true);
369
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T12:11:00Z'), '5-10 * * * *'), false);
370
- });
371
- test('scheduler month names', async () => {
372
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-01-01T00:00:00Z'), '* * * jan *'), true);
373
- assert.equal(defineSchedule(()=>{})._match(new Date('2026-02-01T00:00:00Z'), '* * * jan *'), false);
374
- });
375
- test('scheduler exec runs handlers', async () => {
376
- let ran = 0;
377
- const s = defineSchedule(api => { api.call('a', '* * * * *', async()=>{ ran++; }); });
378
- const r = s.run(new Date());
379
- await r.exec();
380
- assert.equal(ran, 1);
381
- });
382
-
383
- // scheduler — timezone
384
- test('scheduler tz matches in zone not utc', async () => {
385
- const d = new Date('2026-10-18T00:00:00Z'); // 07:00 Asia/Jakarta
386
- assert.equal(defineSchedule(()=>{})._match(d, '0 7 * * *', 'Asia/Jakarta'), true);
387
- assert.equal(defineSchedule(()=>{})._match(d, '0 0 * * *', 'Asia/Jakarta'), false);
388
- assert.equal(defineSchedule(()=>{})._match(d, '0 0 * * *'), true);
389
- });
390
- test('scheduler tz invalid throws', async () => {
391
- assert.throws(() => defineSchedule(()=>{})._match(new Date(), '* * * * *', 'Nope/Zone'), Error);
392
- });
393
-
394
- // queue — delay
395
- test('queue delay not popped before availableAt', async () => {
396
- const q = createQueue({});
397
- await q.push('q', { v: 1 }, { delayMs: 20 });
398
- assert.equal(await q.pop('q'), null);
399
- await new Promise(r => setTimeout(r, 25));
400
- const j = await q.pop('q');
401
- assert.equal(j.payload.v, 1);
402
- });
403
- // queue — retry/backoff then failed
404
- test('queue retry backoff then failed', async () => {
405
- const q = createQueue({});
406
- await q.push('q', { v: 1 }, { tries: 2, backoffMs: 0 });
407
- const j1 = await q.pop('q');
408
- await q.fail('q', j1);
409
- const j2 = await q.pop('q');
410
- assert.equal(j2.attempts, 1);
411
- await q.fail(j2);
412
- assert.equal(await q.pop('q'), null);
413
- const failed = await q.failed('q');
414
- assert.equal(failed.length, 1);
415
- });
416
-
417
- // cache — tags
418
- test('cache tags put/flush isolation', async () => {
419
- const c = createCache({});
420
- const t = c.tags(['tag1']);
421
- await t.put('k1', 'v1');
422
- await c.put('k2', 'v2');
423
- assert.equal(await t.get('k1'), 'v1');
424
- await t.flush();
425
- assert.equal(await t.get('k1'), null);
426
- assert.equal(await c.get('k2'), 'v2');
427
- });
428
- test('cache tags empty throws', async () => {
429
- const c = createCache({});
430
- assert.throws(() => c.tags([]), Error);
431
- });
432
-
433
- // storage — temporary url
434
- test('storage temporaryUrl sign verify', async () => {
435
- const s = createStorage({ secret: 'sec' });
436
- const url = await s.temporaryUrl('a/b.txt', 10000);
437
- assert.equal(await s.verifyTemporaryUrl(url), true);
438
- const tampered = url.replace(/sig=[a-f0-9]+/, 'sig=deadbeef');
439
- assert.equal(await s.verifyTemporaryUrl(tampered, 'sec'), false);
440
- });
441
- test('storage temporaryUrl expired', async () => {
442
- const s = createStorage({});
443
- const url = await s.temporaryUrl('k', 0, { secret: 'sec' });
444
- await new Promise(r => setTimeout(r, 10));
445
- assert.equal(await s.verifyTemporaryUrl(url, 'sec'), false);
446
- });
447
- test('storage temporaryUrl missing secret throws', async () => {
448
- const s = createStorage({});
449
- await assert.rejects(() => s.temporaryUrl('k', 1000), Error);
450
- });
451
-
452
- test('scheduler: the cron expressions answer like the cron library', () => {
453
- // Probed against dragonmantank/cron-expression (the library Laravel uses):
454
- // the day-of-month and the day-of-week are ORed when both are restricted,
455
- // '?' is "no specific value", 'L' is the last day of the month, '<dow>L' is
456
- // the last <weekday> of the month and '<dow>#<n>' is the n-th <weekday>.
457
- const m = (expr, date) => defineSchedule(() => {})._match(new Date(date + 'T00:00:00Z'), expr);
458
- // '0 0 1 * 1' fires on the 1st AND on every Monday (2026-10-01 is a
459
- // Thursday, 2026-10-05 a Monday).
460
- assert.equal(m('0 0 1 * 1', '2026-10-01'), true);
461
- assert.equal(m('0 0 1 * 1', '2026-10-05'), true);
462
- assert.equal(m('0 0 1 * 1', '2026-10-06'), false);
463
- // 'L' is the last day of the month.
464
- assert.equal(m('0 0 L * *', '2026-10-31'), true);
465
- assert.equal(m('0 0 L * *', '2026-10-30'), false);
466
- // '5L' is the last Friday (2026-10-30), '5#2' the second one (2026-10-09).
467
- assert.equal(m('0 0 * * 5L', '2026-10-30'), true);
468
- assert.equal(m('0 0 * * 5L', '2026-10-23'), false);
469
- assert.equal(m('0 0 * * 5#2', '2026-10-09'), true);
470
- assert.equal(m('0 0 * * 5#2', '2026-10-02'), false);
471
- // '?' is "no specific value": the day-of-week decides.
472
- assert.equal(m('0 0 ? * 1', '2026-10-05'), true);
473
- assert.equal(m('0 0 ? * 1', '2026-10-06'), false);
474
- });
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createMailer } from '../mail.mjs';
4
+ import { createQueue } from '../queue.mjs';
5
+ import { createCache } from '../cache.mjs';
6
+ import { createStorage } from '../storage.mjs';
7
+ import { defineSchedule } from '../scheduler.mjs';
8
+
9
+ // mail — log
10
+ test('mail log transport', async () => {
11
+ const mail = createMailer({ transport: 'log', from: 'x@example.com' });
12
+ const r = await mail.send({ to: 'a@b.co', subject: 'hi', html: '<p>hi</p>' });
13
+ assert.ok(r.id.startsWith('log-'));
14
+ assert.equal(mail.outbox[0].to, 'a@b.co');
15
+ });
16
+ test('mail noop', async () => {
17
+ const mail = createMailer({ transport: 'noop' });
18
+ const r = await mail.send({ to: 'a@b.co', subject: 'hi' });
19
+ assert.equal(r.id, 'noop');
20
+ });
21
+ test('mail resend mock fetch', async () => {
22
+ let lastUrl = null;
23
+ let lastOpts = null;
24
+ const fetchImpl = async (url, opts) => { lastUrl = url; lastOpts = opts; return { ok: true, async json(){ return { id: 're_123' }; } }; };
25
+ const mail = createMailer({ transport: 'resend', from: 'from@example.com', key: 're_abc', fetchImpl });
26
+ const r = await mail.send({ to: 'a@b.co', subject: 'hi', html: 'x' });
27
+ assert.equal(r.id, 're_123');
28
+ assert.equal(lastUrl, 'https://api.resend.com/emails');
29
+ assert.equal(lastOpts.headers['Authorization'], 'Bearer re_abc');
30
+ assert.ok(lastOpts.headers['User-Agent']);
31
+ const body = JSON.parse(lastOpts.body);
32
+ assert.equal(body.from, 'from@example.com');
33
+ assert.deepEqual(body.to, ['a@b.co']);
34
+ assert.equal(body.cc, undefined);
35
+ });
36
+ test('mail resend error', async () => {
37
+ const fetchImpl = async () => ({ ok: false, status: 401, async text(){ return 'bad key'; } });
38
+ const mail = createMailer({ transport: 'resend', fetchImpl });
39
+ await assert.rejects(() => mail.send({ to: 'a@b.co', subject: 'hi' }));
40
+ });
41
+
42
+ // queue — memory
43
+ test('queue memory push/pop', async () => {
44
+ const q = createQueue({});
45
+ await q.push('emails', { to: 'a' });
46
+ await q.push('emails', { to: 'b' });
47
+ const a = await q.pop('emails');
48
+ assert.equal(a.payload.to, 'a');
49
+ const b = await q.pop('emails');
50
+ assert.equal(b.payload.to, 'b');
51
+ assert.equal(await q.pop('emails'), null);
52
+ });
53
+ test('queue binding send', async () => {
54
+ const sent = [];
55
+ const binding = { async send(m){ sent.push(m); } };
56
+ const q = createQueue({ binding });
57
+ const j = await q.push('emails', { to: 'a' });
58
+ assert.equal(sent[0].queue, 'emails');
59
+ assert.equal(j.payload.to, 'a');
60
+ assert.equal(await q.pop(), null);
61
+ });
62
+
63
+ // cache — memory
64
+ test('cache memory get/put/forget', async () => {
65
+ const c = createCache({});
66
+ assert.equal(await c.get('k'), null);
67
+ await c.put('k', { v: 1 }, 10000);
68
+ assert.deepEqual(await c.get('k'), { v: 1 });
69
+ await c.forget('k');
70
+ assert.equal(await c.get('k'), null);
71
+ });
72
+ test('cache remember', async () => {
73
+ const c = createCache({});
74
+ let n = 0;
75
+ const v1 = await c.remember('k', 10000, async () => ++n);
76
+ assert.equal(v1, 1);
77
+ const v2 = await c.remember('k', 10000, async () => ++n);
78
+ assert.equal(v2, 1);
79
+ assert.equal(n, 1);
80
+ });
81
+ test('cache ttl expiry', async () => {
82
+ const c = createCache({});
83
+ await c.put('k', 1, 5);
84
+ await new Promise(r => setTimeout(r, 10));
85
+ assert.equal(await c.get('k'), null);
86
+ });
87
+ test('cache kv binding', async () => {
88
+ const store = new Map();
89
+ const kv = { async get(k){ const v=store.get(k); return v===undefined?null:v; }, async put(k,v){ store.set(k,v); }, async delete(k){ store.delete(k); } };
90
+ const c = createCache({ kv });
91
+ await c.put('k', { x: 1 });
92
+ assert.deepEqual(await c.get('k'), { x: 1 });
93
+ await c.forget('k');
94
+ assert.equal(await c.get('k'), null);
95
+ });
96
+
97
+ // The `new` expression: the built-in exceptions become errors with the same
98
+ // class name and message (Laravel answers a 500 for them too).
99
+ test('the runtime: rt.newError keeps the PHP class name and message', async () => {
100
+ const { buildRt } = await import('../../compiler/runtime/rt-from-context.mjs');
101
+ const rt = buildRt(null, { runtime: true });
102
+ const e = rt.newError('RuntimeException', 'boom');
103
+ assert.equal(e.name, 'RuntimeException');
104
+ assert.equal(e.message, 'boom');
105
+ assert.equal(e.phpClass, 'RuntimeException');
106
+ assert.equal(rt.throwIf(false, e), null);
107
+ assert.throws(() => rt.throwIf(true, e), /boom/);
108
+ assert.equal(rt.throwUnless(true, e), null);
109
+ assert.throws(() => rt.throwUnless(false, e), /boom/);
110
+ });
111
+
112
+ // The flash: the redirect's with()/withInput()/withErrors() write into the
113
+ // session (Laravel reads the flash on the next request).
114
+ test('the runtime: the redirect flashes into the session', async () => {
115
+ const { buildRt } = await import('../../compiler/runtime/rt-from-context.mjs');
116
+ const flashed = [];
117
+ const inputFlashed = [];
118
+ const session = {
119
+ flash(key, value) { flashed.push({ key, value }); },
120
+ flashInput(input) { inputFlashed.push(input); },
121
+ token() { return 't'; },
122
+ };
123
+ const rt = buildRt(null, {
124
+ runtime: true,
125
+ http: {
126
+ session,
127
+ request: { all: () => ({ title: 'Flash me', password: 'secret' }) },
128
+ },
129
+ });
130
+ const res = rt.redirect('/x').with('status', 'saved');
131
+ assert.equal(res.status, 302);
132
+ assert.equal(res.headers.location, '/x');
133
+ assert.deepEqual(flashed, [{ key: 'status', value: 'saved' }]);
134
+ rt.redirect('/x').with({ a: 1, b: 2 });
135
+ assert.deepEqual(flashed.slice(1), [{ key: 'a', value: 1 }, { key: 'b', value: 2 }]);
136
+ rt.redirect('/x').withInput();
137
+ assert.deepEqual(inputFlashed, [{ title: 'Flash me' }]);
138
+ });
139
+
140
+ // The helper surface: back(), data_get(), tap(), report() and env().
141
+ test('the runtime: the helper surface (back/dataGet/tap/report/env)', async () => {
142
+ const { buildRt } = await import('../../compiler/runtime/rt-from-context.mjs');
143
+ const logged = [];
144
+ const rt = buildRt(null, {
145
+ runtime: true,
146
+ http: {
147
+ session: { previousUrl: () => '/prev' },
148
+ request: { header: (h) => (h === 'referer' ? '/ref' : null) },
149
+ log: (level, message) => { logged.push({ level, message }); return null; },
150
+ config: { 'app.env': 'local' },
151
+ },
152
+ });
153
+ assert.equal(rt.back().headers.location, '/prev');
154
+ assert.equal(rt.back('/fallback').headers.location, '/prev');
155
+ assert.equal(rt.dataGet({ a: { b: 2 } }, 'a.b'), 2);
156
+ assert.equal(rt.dataGet({ a: [1, 2] }, 'a.1'), 2);
157
+ assert.equal(rt.dataGet({ a: 1 }, 'x', 'def'), 'def');
158
+ assert.equal(rt.tap('v', () => {}), 'v');
159
+ rt.report(new Error('boom'));
160
+ assert.deepEqual(logged, [{ level: 'error', message: 'boom' }]);
161
+ assert.equal(rt.env('APP_ENV'), 'local');
162
+ assert.throws(() => rt.env('NOPE'), /no compiled counterpart/);
163
+ });
164
+
165
+ // The helper verbs: the request clock (now()/today()) and the log sink.
166
+ test('the runtime: now()/today() follow the request clock and rt.log reaches the sink', async () => {
167
+ const { buildRt } = await import('../../compiler/runtime/rt-from-context.mjs');
168
+ const logged = [];
169
+ const rt = buildRt(null, {
170
+ runtime: true,
171
+ http: {
172
+ clock: () => 1767225600, // 2026-01-01T00:00:00Z (the shadow freeze)
173
+ log: (level, message) => { logged.push({ level, message }); return null; },
174
+ },
175
+ });
176
+ assert.equal(rt.now().toDateTimeString(), '2026-01-01 00:00:00');
177
+ assert.equal(rt.today().toDateString(), '2026-01-01');
178
+ assert.equal(rt.abortIf(false, 404, 'never'), null);
179
+ assert.throws(() => rt.abortIf(true, 404, 'gone'), (e) => e.status === 404);
180
+ assert.equal(rt.abortUnless(true, 404, 'never'), null);
181
+ assert.throws(() => rt.abortUnless(false, 500, 'gone'), (e) => e.status === 500);
182
+ assert.equal(rt.log('info', 'hello'), null);
183
+ assert.deepEqual(logged, [{ level: 'info', message: 'hello' }]);
184
+ });
185
+
186
+ // storage — memory
187
+ test('storage memory put/get/delete', async () => {
188
+ const s = createStorage({});
189
+ // Laravel's FilesystemAdapter::put() answers a bool, not the path.
190
+ assert.equal(await s.put('a/b.txt', 'hello'), true);
191
+ const obj = await s.get('a/b.txt');
192
+ assert.equal(obj.body, 'hello');
193
+ assert.equal(s.url('a/b.txt'), '/r2/a/b.txt');
194
+ assert.equal(await s.delete('a/b.txt'), true);
195
+ assert.equal(await s.get('a/b.txt'), null);
196
+ // Laravel answers false when the file was missing.
197
+ assert.equal(await s.delete('a/b.txt'), false);
198
+ });
199
+ test('storage: a missing file fails loud on get (Laravel throws UnableToReadFile)', async () => {
200
+ const s = createStorage({});
201
+ await assert.rejects(() => s.getText('nope.txt'), /does not exist/);
202
+ });
203
+ test('storage r2 binding', async () => {
204
+ const m = new Map();
205
+ const r2 = {
206
+ async put(k,v){ m.set(k,v); },
207
+ async get(k){ const v=m.get(k); return v===undefined?null:{ body: v }; },
208
+ async head(k){ return m.has(k) ? { body: 'x' } : null; },
209
+ async delete(k){ m.delete(k); },
210
+ };
211
+ const s = createStorage({ r2 });
212
+ assert.equal(await s.put('x', 'v'), true);
213
+ assert.equal((await s.get('x')).body, 'v');
214
+ assert.equal(await s.delete('x'), true);
215
+ assert.equal(await s.delete('x'), false);
216
+ });
217
+
218
+ // storage — the D1 blob store (one row per object; no R2, no card)
219
+ function mockD1() {
220
+ const rows = new Map();
221
+ const state = { tables: new Set(), created: 0, statements: [] };
222
+ return {
223
+ state,
224
+ prepare(sql) {
225
+ state.statements.push(sql);
226
+ const stmt = {
227
+ params: [],
228
+ bind(...p) { this.params = p; return this; },
229
+ async first() {
230
+ if (sql.includes('lw_blobs') && !state.tables.has('lw_blobs')) {
231
+ throw new Error('D1_ERROR: no such table: lw_blobs');
232
+ }
233
+ if (/SELECT 1/.test(sql)) return { '1': 1 };
234
+ const row = rows.get(this.params[0]);
235
+ return row === undefined ? null : { body: row.body, size: row.size };
236
+ },
237
+ async run() {
238
+ if (/^CREATE TABLE/.test(sql)) {
239
+ state.tables.add('lw_blobs');
240
+ state.created++;
241
+ return { success: true };
242
+ }
243
+ if (/^INSERT OR REPLACE/.test(sql)) {
244
+ const [key, body, size] = this.params;
245
+ rows.set(key, { body, size });
246
+ return { success: true, meta: { changes: 1 } };
247
+ }
248
+ if (/^DELETE/.test(sql)) {
249
+ const existed = rows.has(this.params[0]);
250
+ rows.delete(this.params[0]);
251
+ return { success: true, meta: { changes: existed ? 1 : 0 } };
252
+ }
253
+ return { success: true };
254
+ },
255
+ async all() { return { results: [] }; },
256
+ };
257
+ return stmt;
258
+ },
259
+ };
260
+ }
261
+
262
+ test('storage d1 blob store: put/get/exists/delete (one row per object)', async () => {
263
+ const d1 = mockD1();
264
+ const s = createStorage({ d1 });
265
+ assert.equal(s.kind, 'd1');
266
+ assert.equal(await s.put('a/b.txt', 'hello'), true);
267
+ assert.equal(await s.getText('a/b.txt'), 'hello');
268
+ assert.equal(await s.exists('a/b.txt'), true);
269
+ assert.equal(s.url('a/b.txt'), '/storage/a/b.txt');
270
+ assert.equal(await s.delete('a/b.txt'), true);
271
+ assert.equal(await s.exists('a/b.txt'), false);
272
+ // Laravel: false when the file was missing; a missing file is not null on get.
273
+ assert.equal(await s.delete('a/b.txt'), false);
274
+ await assert.rejects(() => s.getText('a/b.txt'), /does not exist/);
275
+ });
276
+
277
+ test('storage d1 blob store: the table is created once per binding, not per request', async () => {
278
+ const d1 = mockD1();
279
+ const s = createStorage({ d1 });
280
+ await s.put('k', 'v');
281
+ await s.get('k');
282
+ await s.exists('k');
283
+ assert.equal(d1.state.created, 1, 'the CREATE TABLE ran more than once');
284
+ assert.ok(d1.state.tables.has('lw_blobs'));
285
+ });
286
+
287
+ test('storage d1 blob store: an object over the D1 row limit fails loud', async () => {
288
+ const d1 = mockD1();
289
+ const s = createStorage({ d1 });
290
+ await assert.rejects(() => s.put('big', 'x'.repeat(1500001)), /at most 1500000 bytes/);
291
+ });
292
+
293
+ test('storage: a binary object (an uploaded file) round-trips', async () => {
294
+ // The D1 blob store: the row is TEXT, so the bytes are base64-encoded.
295
+ const d1 = mockD1();
296
+ const s = createStorage({ d1 });
297
+ const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 65, 66, 67]);
298
+ assert.equal(await s.put('a/b.bin', bytes), true);
299
+ const read = await s.get('a/b.bin');
300
+ assert.ok(read.body instanceof Uint8Array, 'the binary object comes back as bytes');
301
+ assert.deepEqual(Array.from(read.body), Array.from(bytes));
302
+ // A text object stays text (no marker).
303
+ await s.put('a/c.txt', 'hello');
304
+ const text = await s.get('a/c.txt');
305
+ assert.equal(text.body, 'hello');
306
+ // The R2 path: the bytes as they are.
307
+ const m = new Map();
308
+ const r2 = {
309
+ async put(k, v) { m.set(k, v); },
310
+ async get(k) { const v = m.get(k); return v === undefined ? null : { body: v }; },
311
+ async delete(k) { m.delete(k); },
312
+ };
313
+ const s2 = createStorage({ r2 });
314
+ await s2.put('a/b.bin', bytes);
315
+ const read2 = await s2.get('a/b.bin');
316
+ assert.deepEqual(Array.from(read2.body), Array.from(bytes));
317
+ });
318
+
319
+ test('storage: r2 wins over d1 when the app binds both', async () => {
320
+ const m = new Map();
321
+ const r2 = { async put(k,v){ m.set(k,v); }, async get(k){ const v=m.get(k); return v===undefined?null:{ body: v }; }, async delete(k){ m.delete(k); } };
322
+ const s = createStorage({ r2, d1: mockD1() });
323
+ assert.equal(s.kind, 'r2');
324
+ assert.equal(s.url('x'), '/r2/x');
325
+ });
326
+
327
+ // scheduler — cron
328
+ test('the dumped schedule: dueEvents picks the events that fire now', async () => {
329
+ const { dueEvents, cronMatches } = await import('../scheduler.mjs');
330
+ const events = [
331
+ { name: 'posts:prune', expression: '*/5 * * * *', command: 'posts:prune', timezone: 'UTC' },
332
+ { name: 'inspire', expression: '0 9 * * *', command: 'inspire', timezone: 'UTC' },
333
+ { name: 'broken', expression: '', command: 'x' },
334
+ ];
335
+ assert.equal(cronMatches('*/5 * * * *', new Date('2026-10-18T12:15:00Z')), true);
336
+ assert.equal(cronMatches('*/5 * * * *', new Date('2026-10-18T12:16:00Z')), false);
337
+ const due = dueEvents(events, new Date('2026-10-18T12:15:00Z'));
338
+ assert.equal(due.length, 1);
339
+ assert.equal(due[0].name, 'posts:prune');
340
+ // An event without an expression is never due (and never throws).
341
+ assert.equal(dueEvents([{ name: 'x' }], new Date('2026-10-18T12:15:00Z')).length, 0);
342
+ // A broken expression is skipped, not fatal.
343
+ assert.equal(dueEvents([{ name: 'y', expression: 'not a cron' }], new Date('2026-10-18T12:15:00Z')).length, 0);
344
+ });
345
+ test('scheduler every minute', async () => {
346
+ const s = defineSchedule(api => { api.call('a', '* * * * *', async()=>{}); });
347
+ const d = new Date('2026-10-18T12:34:00Z');
348
+ assert.equal(s._match(d, '* * * * *'), true);
349
+ const r = s.run(d);
350
+ assert.equal(r.due.length, 1);
351
+ });
352
+ test('scheduler minute step', async () => {
353
+ const s = defineSchedule(api => { api.call('a', '*/15 * * * *', async()=>{}); });
354
+ assert.equal(s._match(new Date('2026-10-18T12:15:00Z'), '*/15 * * * *'), true);
355
+ assert.equal(s._match(new Date('2026-10-18T12:16:00Z'), '*/15 * * * *'), false);
356
+ });
357
+ test('scheduler hour and dow', async () => {
358
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T09:00:00Z'), '0 9 * * *'), true);
359
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T09:00:00Z'), '0 10 * * *'), false);
360
+ // 2026-10-18 is Sunday (0)
361
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T00:00:00Z'), '* * * * 0'), true);
362
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T00:00:00Z'), '* * * * 7'), true);
363
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T00:00:00Z'), '* * * * 1'), false);
364
+ });
365
+ test('scheduler list and range', async () => {
366
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T12:01:00Z'), '0,15,30 * * * *'), false);
367
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T12:15:00Z'), '0,15,30 * * * *'), true);
368
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T12:07:00Z'), '5-10 * * * *'), true);
369
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-10-18T12:11:00Z'), '5-10 * * * *'), false);
370
+ });
371
+ test('scheduler month names', async () => {
372
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-01-01T00:00:00Z'), '* * * jan *'), true);
373
+ assert.equal(defineSchedule(()=>{})._match(new Date('2026-02-01T00:00:00Z'), '* * * jan *'), false);
374
+ });
375
+ test('scheduler exec runs handlers', async () => {
376
+ let ran = 0;
377
+ const s = defineSchedule(api => { api.call('a', '* * * * *', async()=>{ ran++; }); });
378
+ const r = s.run(new Date());
379
+ await r.exec();
380
+ assert.equal(ran, 1);
381
+ });
382
+
383
+ // scheduler — timezone
384
+ test('scheduler tz matches in zone not utc', async () => {
385
+ const d = new Date('2026-10-18T00:00:00Z'); // 07:00 Asia/Jakarta
386
+ assert.equal(defineSchedule(()=>{})._match(d, '0 7 * * *', 'Asia/Jakarta'), true);
387
+ assert.equal(defineSchedule(()=>{})._match(d, '0 0 * * *', 'Asia/Jakarta'), false);
388
+ assert.equal(defineSchedule(()=>{})._match(d, '0 0 * * *'), true);
389
+ });
390
+ test('scheduler tz invalid throws', async () => {
391
+ assert.throws(() => defineSchedule(()=>{})._match(new Date(), '* * * * *', 'Nope/Zone'), Error);
392
+ });
393
+
394
+ // queue — delay
395
+ test('queue delay not popped before availableAt', async () => {
396
+ const q = createQueue({});
397
+ await q.push('q', { v: 1 }, { delayMs: 20 });
398
+ assert.equal(await q.pop('q'), null);
399
+ await new Promise(r => setTimeout(r, 25));
400
+ const j = await q.pop('q');
401
+ assert.equal(j.payload.v, 1);
402
+ });
403
+ // queue — retry/backoff then failed
404
+ test('queue retry backoff then failed', async () => {
405
+ const q = createQueue({});
406
+ await q.push('q', { v: 1 }, { tries: 2, backoffMs: 0 });
407
+ const j1 = await q.pop('q');
408
+ await q.fail('q', j1);
409
+ const j2 = await q.pop('q');
410
+ assert.equal(j2.attempts, 1);
411
+ await q.fail(j2);
412
+ assert.equal(await q.pop('q'), null);
413
+ const failed = await q.failed('q');
414
+ assert.equal(failed.length, 1);
415
+ });
416
+
417
+ // cache — tags
418
+ test('cache tags put/flush isolation', async () => {
419
+ const c = createCache({});
420
+ const t = c.tags(['tag1']);
421
+ await t.put('k1', 'v1');
422
+ await c.put('k2', 'v2');
423
+ assert.equal(await t.get('k1'), 'v1');
424
+ await t.flush();
425
+ assert.equal(await t.get('k1'), null);
426
+ assert.equal(await c.get('k2'), 'v2');
427
+ });
428
+ test('cache tags empty throws', async () => {
429
+ const c = createCache({});
430
+ assert.throws(() => c.tags([]), Error);
431
+ });
432
+
433
+ // storage — temporary url
434
+ test('storage temporaryUrl sign verify', async () => {
435
+ const s = createStorage({ secret: 'sec' });
436
+ const url = await s.temporaryUrl('a/b.txt', 10000);
437
+ assert.equal(await s.verifyTemporaryUrl(url), true);
438
+ const tampered = url.replace(/sig=[a-f0-9]+/, 'sig=deadbeef');
439
+ assert.equal(await s.verifyTemporaryUrl(tampered, 'sec'), false);
440
+ });
441
+ test('storage temporaryUrl expired', async () => {
442
+ const s = createStorage({});
443
+ const url = await s.temporaryUrl('k', 0, { secret: 'sec' });
444
+ await new Promise(r => setTimeout(r, 10));
445
+ assert.equal(await s.verifyTemporaryUrl(url, 'sec'), false);
446
+ });
447
+ test('storage temporaryUrl missing secret throws', async () => {
448
+ const s = createStorage({});
449
+ await assert.rejects(() => s.temporaryUrl('k', 1000), Error);
450
+ });
451
+
452
+ test('scheduler: the cron expressions answer like the cron library', () => {
453
+ // Probed against dragonmantank/cron-expression (the library Laravel uses):
454
+ // the day-of-month and the day-of-week are ORed when both are restricted,
455
+ // '?' is "no specific value", 'L' is the last day of the month, '<dow>L' is
456
+ // the last <weekday> of the month and '<dow>#<n>' is the n-th <weekday>.
457
+ const m = (expr, date) => defineSchedule(() => {})._match(new Date(date + 'T00:00:00Z'), expr);
458
+ // '0 0 1 * 1' fires on the 1st AND on every Monday (2026-10-01 is a
459
+ // Thursday, 2026-10-05 a Monday).
460
+ assert.equal(m('0 0 1 * 1', '2026-10-01'), true);
461
+ assert.equal(m('0 0 1 * 1', '2026-10-05'), true);
462
+ assert.equal(m('0 0 1 * 1', '2026-10-06'), false);
463
+ // 'L' is the last day of the month.
464
+ assert.equal(m('0 0 L * *', '2026-10-31'), true);
465
+ assert.equal(m('0 0 L * *', '2026-10-30'), false);
466
+ // '5L' is the last Friday (2026-10-30), '5#2' the second one (2026-10-09).
467
+ assert.equal(m('0 0 * * 5L', '2026-10-30'), true);
468
+ assert.equal(m('0 0 * * 5L', '2026-10-23'), false);
469
+ assert.equal(m('0 0 * * 5#2', '2026-10-09'), true);
470
+ assert.equal(m('0 0 * * 5#2', '2026-10-02'), false);
471
+ // '?' is "no specific value": the day-of-week decides.
472
+ assert.equal(m('0 0 ? * 1', '2026-10-05'), true);
473
+ assert.equal(m('0 0 ? * 1', '2026-10-06'), false);
474
+ });