|
1 | 1 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
2 | | -import { start } from "../src/client.js"; |
| 2 | +import { schedule, start } from "../src/client.js"; |
3 | 3 | import { cronClient, findFreePort, uniqueName } from "./harness.js"; |
4 | 4 |
|
| 5 | +describe("start() — input validation", () => { |
| 6 | + const serverUrl = process.env["QUEUE_TEST_URL"]!; |
| 7 | + |
| 8 | + it("throws for an invalid job name", async () => { |
| 9 | + const job = schedule({ |
| 10 | + name: "Bad Name", |
| 11 | + every: "1h", |
| 12 | + run: async () => {}, |
| 13 | + }); |
| 14 | + await expect(start([job], { url: serverUrl })).rejects.toThrow( |
| 15 | + /Invalid schedule name/, |
| 16 | + ); |
| 17 | + }); |
| 18 | + |
| 19 | + it("throws for duplicate job names", async () => { |
| 20 | + const job = schedule({ name: "my-job", every: "1h", run: async () => {} }); |
| 21 | + await expect(start([job, job], { url: serverUrl })).rejects.toThrow( |
| 22 | + /Duplicate schedule name/, |
| 23 | + ); |
| 24 | + }); |
| 25 | + |
| 26 | + it("throws when BEYOND_QUEUE_URL is missing and no url option provided", async () => { |
| 27 | + const job = schedule({ name: "my-job", every: "1h", run: async () => {} }); |
| 28 | + const saved = process.env["BEYOND_QUEUE_URL"]; |
| 29 | + delete process.env["BEYOND_QUEUE_URL"]; |
| 30 | + try { |
| 31 | + await expect(start([job])).rejects.toThrow(/BEYOND_QUEUE_URL/); |
| 32 | + } finally { |
| 33 | + if (saved !== undefined) process.env["BEYOND_QUEUE_URL"] = saved; |
| 34 | + } |
| 35 | + }); |
| 36 | + |
| 37 | + it("throws when BEYOND_INTERNAL_URL is missing", async () => { |
| 38 | + const job = schedule({ name: "my-job", every: "1h", run: async () => {} }); |
| 39 | + const saved = process.env["BEYOND_INTERNAL_URL"]; |
| 40 | + delete process.env["BEYOND_INTERNAL_URL"]; |
| 41 | + try { |
| 42 | + await expect(start([job], { url: serverUrl })).rejects.toThrow( |
| 43 | + /BEYOND_INTERNAL_URL/, |
| 44 | + ); |
| 45 | + } finally { |
| 46 | + if (saved !== undefined) process.env["BEYOND_INTERNAL_URL"] = saved; |
| 47 | + } |
| 48 | + }); |
| 49 | +}); |
| 50 | + |
5 | 51 | describe("start() — worker lifecycle", () => { |
6 | 52 | const client = cronClient(); |
7 | 53 | const serverUrl = process.env["QUEUE_TEST_URL"]!; |
@@ -250,6 +296,105 @@ describe("start() — worker lifecycle", () => { |
250 | 296 | } |
251 | 297 | }); |
252 | 298 |
|
| 299 | + it("passes CronContext with correct name, ISO-8601 scheduledFor, and boolean outOfBand", async () => { |
| 300 | + const name = uniqueName("ctx"); |
| 301 | + cleanupNames.push(name); |
| 302 | + const port = await findFreePort(); |
| 303 | + const ac = new AbortController(); |
| 304 | + let capturedCtx: |
| 305 | + | { name: string; scheduledFor: string; outOfBand: boolean } |
| 306 | + | undefined; |
| 307 | + |
| 308 | + const done = start( |
| 309 | + [{ |
| 310 | + name, |
| 311 | + spec: { name, every: "1h" }, |
| 312 | + handler: async (ctx) => { |
| 313 | + capturedCtx = ctx; |
| 314 | + }, |
| 315 | + }], |
| 316 | + { url: serverUrl, port, signal: ac.signal }, |
| 317 | + ); |
| 318 | + |
| 319 | + await new Promise<void>((r) => setTimeout(r, 200)); |
| 320 | + |
| 321 | + try { |
| 322 | + const res = await fetch(`http://127.0.0.1:${port}/__cron/${name}`, { |
| 323 | + method: "POST", |
| 324 | + body: "{}", |
| 325 | + headers: { "content-type": "application/json" }, |
| 326 | + }); |
| 327 | + expect(res.status).toBe(200); |
| 328 | + expect(capturedCtx?.name).toBe(name); |
| 329 | + expect(capturedCtx?.scheduledFor).toMatch( |
| 330 | + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, |
| 331 | + ); |
| 332 | + expect(typeof capturedCtx?.outOfBand).toBe("boolean"); |
| 333 | + } finally { |
| 334 | + ac.abort(); |
| 335 | + await done; |
| 336 | + } |
| 337 | + }); |
| 338 | + |
| 339 | + it("returns 405 for non-POST requests", async () => { |
| 340 | + const name = uniqueName("405"); |
| 341 | + cleanupNames.push(name); |
| 342 | + const port = await findFreePort(); |
| 343 | + const ac = new AbortController(); |
| 344 | + |
| 345 | + const done = start( |
| 346 | + [{ name, spec: { name, every: "1h" }, handler: async () => {} }], |
| 347 | + { url: serverUrl, port, signal: ac.signal }, |
| 348 | + ); |
| 349 | + |
| 350 | + await new Promise<void>((r) => setTimeout(r, 200)); |
| 351 | + |
| 352 | + try { |
| 353 | + const res = await fetch(`http://127.0.0.1:${port}/__cron/${name}`, { |
| 354 | + method: "GET", |
| 355 | + }); |
| 356 | + expect(res.status).toBe(405); |
| 357 | + } finally { |
| 358 | + ac.abort(); |
| 359 | + await done; |
| 360 | + } |
| 361 | + }); |
| 362 | + |
| 363 | + it("resolves cleanly when aborted during an in-flight handler", async () => { |
| 364 | + const name = uniqueName("inflight"); |
| 365 | + cleanupNames.push(name); |
| 366 | + const port = await findFreePort(); |
| 367 | + const ac = new AbortController(); |
| 368 | + let handlerStarted = false; |
| 369 | + |
| 370 | + const done = start( |
| 371 | + [{ |
| 372 | + name, |
| 373 | + spec: { name, every: "1h" }, |
| 374 | + handler: async () => { |
| 375 | + handlerStarted = true; |
| 376 | + await new Promise<void>((r) => setTimeout(r, 500)); |
| 377 | + }, |
| 378 | + }], |
| 379 | + { url: serverUrl, port, signal: ac.signal }, |
| 380 | + ); |
| 381 | + |
| 382 | + await new Promise<void>((r) => setTimeout(r, 200)); |
| 383 | + |
| 384 | + // Fire the handler without awaiting the response |
| 385 | + fetch(`http://127.0.0.1:${port}/__cron/${name}`, { |
| 386 | + method: "POST", |
| 387 | + body: "{}", |
| 388 | + }).catch(() => {}); |
| 389 | + |
| 390 | + // Let the handler begin, then abort |
| 391 | + await new Promise<void>((r) => setTimeout(r, 50)); |
| 392 | + expect(handlerStarted).toBe(true); |
| 393 | + |
| 394 | + ac.abort(); |
| 395 | + await expect(done).resolves.toBeUndefined(); |
| 396 | + }); |
| 397 | + |
253 | 398 | it("shuts down cleanly on signal abort", async () => { |
254 | 399 | const name = uniqueName("shut"); |
255 | 400 | cleanupNames.push(name); |
|
0 commit comments