89 lines
2.8 KiB
TypeScript
89 lines
2.8 KiB
TypeScript
/// <reference types="bun" />
|
|
|
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
|
|
import type { BackgroundJob } from "../../web/src/server/jobs/queue";
|
|
import { sendScheduledInvoice } from "../src/send-invoice";
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalAppUrl = process.env.APP_INTERNAL_URL;
|
|
const originalSecret = process.env.CRON_SECRET;
|
|
|
|
function scheduledJob(): BackgroundJob {
|
|
const now = new Date("2026-08-17T16:00:00.000Z");
|
|
return {
|
|
id: "job-1",
|
|
type: "invoice.send_scheduled",
|
|
payload: {
|
|
invoiceId: "invoice-1",
|
|
actorUserId: "user-1",
|
|
customMessage: "Thanks!",
|
|
timeZone: "America/New_York",
|
|
},
|
|
status: "processing",
|
|
idempotencyKey: "invoice.send_scheduled:invoice-1:once",
|
|
runAt: now,
|
|
attempts: 1,
|
|
maxAttempts: 5,
|
|
lockedAt: now,
|
|
lockedBy: "worker-1",
|
|
lastError: null,
|
|
completedAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
if (originalAppUrl === undefined) delete process.env.APP_INTERNAL_URL;
|
|
else process.env.APP_INTERNAL_URL = originalAppUrl;
|
|
if (originalSecret === undefined) delete process.env.CRON_SECRET;
|
|
else process.env.CRON_SECRET = originalSecret;
|
|
});
|
|
|
|
describe("scheduled invoice delivery", () => {
|
|
test("calls the internal app endpoint with auth and an idempotency key", async () => {
|
|
process.env.APP_INTERNAL_URL = "http://app:3000/";
|
|
process.env.CRON_SECRET = "worker-secret";
|
|
const fetchMock = mock(
|
|
async (_url: string | URL | Request, _request?: RequestInit) =>
|
|
Response.json({ success: true, emailId: "email-1" }),
|
|
);
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
await sendScheduledInvoice(scheduledJob());
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, request] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe("http://app:3000/api/internal/jobs/send-invoice");
|
|
expect(request?.headers).toEqual({
|
|
Authorization: "Bearer worker-secret",
|
|
"Content-Type": "application/json",
|
|
});
|
|
expect(JSON.parse(String(request?.body))).toMatchObject({
|
|
jobId: "job-1",
|
|
invoiceId: "invoice-1",
|
|
actorUserId: "user-1",
|
|
idempotencyKey: "invoice.send_scheduled:invoice-1:once",
|
|
timeZone: "America/New_York",
|
|
});
|
|
});
|
|
|
|
test("surfaces retryable delivery errors to the worker", async () => {
|
|
process.env.APP_INTERNAL_URL = "http://app:3000";
|
|
process.env.CRON_SECRET = "worker-secret";
|
|
globalThis.fetch = mock(
|
|
async (_url: string | URL | Request, _request?: RequestInit) =>
|
|
Response.json(
|
|
{ error: "Email service is unavailable" },
|
|
{ status: 503 },
|
|
),
|
|
) as unknown as typeof fetch;
|
|
|
|
await expect(sendScheduledInvoice(scheduledJob())).rejects.toThrow(
|
|
"Email service is unavailable",
|
|
);
|
|
});
|
|
});
|