mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
158 lines
6.3 KiB
TypeScript
158 lines
6.3 KiB
TypeScript
import { EmailOutboxCreatedWith } from "@/generated/prisma/client";
|
|
import { globalPrismaClient } from "@/prisma-client";
|
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
import { _forTesting } from "./email-queue-step";
|
|
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "./tenancies";
|
|
|
|
const { failEmailsStuckInSending, STUCK_EMAIL_TIMEOUT_MS, updateLastExecutionTime } = _forTesting;
|
|
|
|
describe.sequential("updateLastExecutionTime", () => {
|
|
const metadataKeys: string[] = [];
|
|
|
|
afterAll(async () => {
|
|
await globalPrismaClient.emailOutboxProcessingMetadata.deleteMany({
|
|
where: { key: { in: metadataKeys } },
|
|
});
|
|
});
|
|
|
|
it("does not move lastExecutedAt backwards when the stored timestamp is ahead", async () => {
|
|
const key = `email-queue-step-delta-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
metadataKeys.push(key);
|
|
|
|
const futureTimestamp = new Date(Date.now() + 60_000);
|
|
await globalPrismaClient.emailOutboxProcessingMetadata.create({
|
|
data: {
|
|
key,
|
|
lastExecutedAt: futureTimestamp,
|
|
},
|
|
});
|
|
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
const delta = await updateLastExecutionTime(key);
|
|
|
|
expect(delta).toBe(0);
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
|
|
const after = await globalPrismaClient.emailOutboxProcessingMetadata.findUniqueOrThrow({
|
|
where: { key },
|
|
});
|
|
expect(after.lastExecutedAt?.toISOString()).toBe(futureTimestamp.toISOString());
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
// These tests connect to the real dev DB (like payments.test.tsx) and create real EmailOutbox
|
|
// rows against the seeded `internal` tenancy. Each row is tagged with a unique tsxSource so we
|
|
// can find and clean up just our test rows.
|
|
describe.sequential("failEmailsStuckInSending", () => {
|
|
const testRunTag = `stuck-in-sending-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const createdIds: { tenancyId: string, id: string }[] = [];
|
|
|
|
const recoveryTestFilter = { tsxSource: `/* ${testRunTag} */` };
|
|
|
|
const makeRow = async (params: {
|
|
startedSendingAt: Date | null,
|
|
finishedSendingAt?: Date | null,
|
|
isPaused?: boolean,
|
|
sendRetries?: number,
|
|
nextSendRetryAt?: Date | null,
|
|
}) => {
|
|
const tenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
|
|
const created = await globalPrismaClient.emailOutbox.create({
|
|
data: {
|
|
tenancyId: tenancy.id,
|
|
tsxSource: recoveryTestFilter.tsxSource,
|
|
themeId: null,
|
|
isHighPriority: false,
|
|
to: { type: "custom-emails", emails: ["stuck-test@example.com"] },
|
|
extraRenderVariables: {},
|
|
shouldSkipDeliverabilityCheck: true,
|
|
createdWith: EmailOutboxCreatedWith.PROGRAMMATIC_CALL,
|
|
scheduledAt: new Date(0),
|
|
isQueued: true,
|
|
renderedByWorkerId: "00000000-0000-0000-0000-000000000000",
|
|
startedRenderingAt: new Date(0),
|
|
finishedRenderingAt: new Date(0),
|
|
renderedHtml: "<p>stuck</p>",
|
|
renderedText: "stuck",
|
|
renderedSubject: "stuck",
|
|
renderedIsTransactional: false,
|
|
startedSendingAt: params.startedSendingAt,
|
|
finishedSendingAt: params.finishedSendingAt ?? null,
|
|
sendRetries: params.sendRetries ?? 0,
|
|
nextSendRetryAt: params.nextSendRetryAt ?? null,
|
|
isPaused: params.isPaused ?? false,
|
|
},
|
|
});
|
|
createdIds.push({ tenancyId: created.tenancyId, id: created.id });
|
|
return created;
|
|
};
|
|
|
|
afterAll(async () => {
|
|
for (const { tenancyId, id } of createdIds) {
|
|
await globalPrismaClient.emailOutbox.deleteMany({ where: { tenancyId, id } });
|
|
}
|
|
});
|
|
|
|
it("marks a row as failed when startedSendingAt is older than the stuck timeout", async () => {
|
|
const longAgo = new Date(Date.now() - STUCK_EMAIL_TIMEOUT_MS - 60_000);
|
|
const row = await makeRow({
|
|
startedSendingAt: longAgo,
|
|
sendRetries: 1,
|
|
nextSendRetryAt: new Date(Date.now() + 60_000),
|
|
});
|
|
|
|
await failEmailsStuckInSending(recoveryTestFilter);
|
|
|
|
const after = await globalPrismaClient.emailOutbox.findUniqueOrThrow({
|
|
where: { tenancyId_id: { tenancyId: row.tenancyId, id: row.id } },
|
|
});
|
|
expect(after.finishedSendingAt).not.toBeNull();
|
|
expect(after.startedSendingAt?.toISOString()).toBe(row.startedSendingAt?.toISOString());
|
|
expect(after.canHaveDeliveryInfo).toBe(false);
|
|
expect(after.sendServerErrorExternalMessage).toMatch(/timed out/i);
|
|
expect(after.sendServerErrorInternalMessage).toMatch(/stuck in sending/i);
|
|
expect(after.sendServerErrorInternalMessage).toMatch(/terminal server error/i);
|
|
// Must be a terminal state — no retry scheduled.
|
|
expect(after.nextSendRetryAt).toBeNull();
|
|
// sendRetries is not bumped by this path (we never attempted the send again).
|
|
expect(after.sendRetries).toBe(row.sendRetries);
|
|
// Status must be SERVER_ERROR, not SENDING.
|
|
expect(after.status).toBe("SERVER_ERROR");
|
|
});
|
|
|
|
it("does not touch a row that started sending recently", async () => {
|
|
const recently = new Date(Date.now() - 1000);
|
|
const row = await makeRow({ startedSendingAt: recently });
|
|
|
|
await failEmailsStuckInSending(recoveryTestFilter);
|
|
|
|
const after = await globalPrismaClient.emailOutbox.findUniqueOrThrow({
|
|
where: { tenancyId_id: { tenancyId: row.tenancyId, id: row.id } },
|
|
});
|
|
expect(after.finishedSendingAt).toBeNull();
|
|
expect(after.sendServerErrorExternalMessage).toBeNull();
|
|
expect(after.status).toBe("SENDING");
|
|
});
|
|
|
|
it("does not re-queue rows already marked failed for another send attempt", async () => {
|
|
const longAgo = new Date(Date.now() - STUCK_EMAIL_TIMEOUT_MS - 60_000);
|
|
const row = await makeRow({ startedSendingAt: longAgo });
|
|
|
|
await failEmailsStuckInSending(recoveryTestFilter);
|
|
// A second pass should be a no-op for this row: it's already terminal, so it must not
|
|
// become a candidate for re-sending (which could duplicate an already-accepted delivery).
|
|
await failEmailsStuckInSending(recoveryTestFilter);
|
|
|
|
const after = await globalPrismaClient.emailOutbox.findUniqueOrThrow({
|
|
where: { tenancyId_id: { tenancyId: row.tenancyId, id: row.id } },
|
|
});
|
|
expect(after.nextSendRetryAt).toBeNull();
|
|
expect(after.isQueued).toBe(true); // unchanged: we do not unclaim stuck rows
|
|
expect(after.status).toBe("SERVER_ERROR");
|
|
});
|
|
});
|