diff --git a/.github/workflows/e2e-api-tests.yaml b/.github/workflows/e2e-api-tests.yaml index 6aa1e55e2d..3d33c11066 100644 --- a/.github/workflows/e2e-api-tests.yaml +++ b/.github/workflows/e2e-api-tests.yaml @@ -19,7 +19,6 @@ jobs: NODE_ENV: test STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: yes STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/stackframe" - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" @@ -160,7 +159,7 @@ jobs: run: sleep 10 - name: Run tests - run: pnpm test run ${{ matrix.freestyle-mode == 'prod' && '--min-workers=1 --max-workers=1' || '' }} + run: pnpm test run ${{ matrix.freestyle-mode == 'prod' && '--min-workers=1 --max-workers=1' || '' }} ${{ matrix.freestyle-mode == 'prod' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' && 'mail' || '' }} - name: Run tests again (attempt 1) if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' diff --git a/.github/workflows/e2e-custom-base-port-api-tests.yaml b/.github/workflows/e2e-custom-base-port-api-tests.yaml index 1a1cf52efe..d458b0653d 100644 --- a/.github/workflows/e2e-custom-base-port-api-tests.yaml +++ b/.github/workflows/e2e-custom-base-port-api-tests.yaml @@ -19,7 +19,6 @@ jobs: STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: yes STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:6728/stackframe" NEXT_PUBLIC_STACK_PORT_PREFIX: "67" - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" diff --git a/.github/workflows/e2e-source-of-truth-api-tests.yaml b/.github/workflows/e2e-source-of-truth-api-tests.yaml index 99b66e51b6..46f5cebfc5 100644 --- a/.github/workflows/e2e-source-of-truth-api-tests.yaml +++ b/.github/workflows/e2e-source-of-truth-api-tests.yaml @@ -21,7 +21,6 @@ jobs: STACK_OVERRIDE_SOURCE_OF_TRUTH: '{"type": "postgres", "connectionString": "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/source-of-truth-db?schema=sot-schema"}' STACK_TEST_SOURCE_OF_TRUTH: true STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/stackframe" - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" diff --git a/.github/workflows/restart-dev-and-test-with-custom-base-port.yaml b/.github/workflows/restart-dev-and-test-with-custom-base-port.yaml index 65179046fb..75ccb222e3 100644 --- a/.github/workflows/restart-dev-and-test-with-custom-base-port.yaml +++ b/.github/workflows/restart-dev-and-test-with-custom-base-port.yaml @@ -19,7 +19,6 @@ jobs: runs-on: ubicloud-standard-16 env: NEXT_PUBLIC_STACK_PORT_PREFIX: "69" - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" diff --git a/.github/workflows/restart-dev-and-test.yaml b/.github/workflows/restart-dev-and-test.yaml index 6d091edb3c..e9e133aa35 100644 --- a/.github/workflows/restart-dev-and-test.yaml +++ b/.github/workflows/restart-dev-and-test.yaml @@ -18,7 +18,6 @@ jobs: restart-dev-and-test: runs-on: ubicloud-standard-16 env: - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" diff --git a/.github/workflows/setup-tests-with-custom-base-port.yaml b/.github/workflows/setup-tests-with-custom-base-port.yaml index 0c9ac4a6ca..95a04f2896 100644 --- a/.github/workflows/setup-tests-with-custom-base-port.yaml +++ b/.github/workflows/setup-tests-with-custom-base-port.yaml @@ -19,7 +19,6 @@ jobs: runs-on: ubicloud-standard-16 env: NEXT_PUBLIC_STACK_PORT_PREFIX: "69" - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" diff --git a/.github/workflows/setup-tests.yaml b/.github/workflows/setup-tests.yaml index 66374120c5..fe81c28ecc 100644 --- a/.github/workflows/setup-tests.yaml +++ b/.github/workflows/setup-tests.yaml @@ -18,7 +18,6 @@ jobs: setup-tests: runs-on: ubicloud-standard-16 env: - STACK_FORCE_EXTERNAL_DB_SYNC: "true" STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" STACK_EXTERNAL_DB_SYNC_DIRECT: "false" steps: diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts index a12edab49f..995f8fbdaf 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts @@ -30,13 +30,6 @@ function parseMaxDurationMs(value: string | undefined): number { return parsed; } -function parseStopWhenIdle(value: string | undefined): boolean { - if (!value) return false; - if (value === "true") return true; - if (value === "false") return false; - throw new StatusError(400, "stopWhenIdle must be 'true' or 'false'"); -} - function directSyncEnabled(): boolean { return getEnvVariable(DIRECT_SYNC_ENV, "") === "true"; } @@ -69,7 +62,6 @@ export const GET = createSmartRouteHandler({ }).defined(), query: yupObject({ maxDurationMs: yupString().optional(), - stopWhenIdle: yupString().optional(), }).defined(), }), response: yupObject({ @@ -91,13 +83,11 @@ export const GET = createSmartRouteHandler({ return await traceSpan("external-db-sync.poller", async (span) => { const startTime = performance.now(); const maxDurationMs = parseMaxDurationMs(query.maxDurationMs); - const stopWhenIdle = parseStopWhenIdle(query.stopWhenIdle); const pollIntervalMs = 50; const staleClaimIntervalMinutes = 5; const pollerClaimLimit = getPollerClaimLimit(); span.setAttribute("stack.external-db-sync.max-duration-ms", maxDurationMs); - span.setAttribute("stack.external-db-sync.stop-when-idle", stopWhenIdle); span.setAttribute("stack.external-db-sync.poll-interval-ms", pollIntervalMs); span.setAttribute("stack.external-db-sync.poller-claim-limit", pollerClaimLimit); span.setAttribute("stack.external-db-sync.direct-sync", directSyncEnabled()); @@ -235,7 +225,7 @@ export const GET = createSmartRouteHandler({ } type PollerIterationResult = { - stopReason: "disabled" | "idle" | null, + stopReason: "disabled" | null, processed: number, }; @@ -255,10 +245,6 @@ export const GET = createSmartRouteHandler({ const pendingRequests = await claimPendingRequests(); iterationSpan.setAttribute("stack.external-db-sync.pending-count", pendingRequests.length); - if (stopWhenIdle && pendingRequests.length === 0) { - return { stopReason: "idle", processed: 0 }; - } - const processed = await processRequests(pendingRequests); iterationSpan.setAttribute("stack.external-db-sync.processed-count", processed); return { stopReason: null, processed }; diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts index 7a51da1f27..e12ff716ce 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts @@ -27,13 +27,6 @@ function parseMaxDurationMs(value: string | undefined): number { return parsed; } -function parseStopWhenIdle(value: string | undefined): boolean { - if (!value) return false; - if (value === "true") return true; - if (value === "false") return false; - throw new StatusError(400, "stopWhenIdle must be 'true' or 'false'"); -} - function getSequencerBatchSize(): number { const rawValue = getEnvVariable(SEQUENCER_BATCH_SIZE_ENV, ""); if (!rawValue) return DEFAULT_BATCH_SIZE; @@ -169,7 +162,6 @@ export const GET = createSmartRouteHandler({ }).defined(), query: yupObject({ maxDurationMs: yupString().optional(), - stopWhenIdle: yupString().optional(), }).defined(), }), response: yupObject({ @@ -190,19 +182,17 @@ export const GET = createSmartRouteHandler({ return await traceSpan("external-db-sync.sequencer", async (span) => { const startTime = performance.now(); const maxDurationMs = parseMaxDurationMs(query.maxDurationMs); - const stopWhenIdle = parseStopWhenIdle(query.stopWhenIdle); const pollIntervalMs = 50; const batchSize = getSequencerBatchSize(); span.setAttribute("stack.external-db-sync.max-duration-ms", maxDurationMs); - span.setAttribute("stack.external-db-sync.stop-when-idle", stopWhenIdle); span.setAttribute("stack.external-db-sync.poll-interval-ms", pollIntervalMs); span.setAttribute("stack.external-db-sync.batch-size", batchSize); let iterations = 0; type SequencerIterationResult = { - stopReason: "disabled" | "idle" | null, + stopReason: "disabled" | null, }; while (performance.now() - startTime < maxDurationMs) { @@ -221,9 +211,6 @@ export const GET = createSmartRouteHandler({ try { const didUpdate = await backfillSequenceIds(batchSize); iterationSpan.setAttribute("stack.external-db-sync.did-update", didUpdate); - if (stopWhenIdle && !didUpdate) { - return { stopReason: "idle" }; - } } catch (error) { iterationSpan.setAttribute("stack.external-db-sync.iteration-error", true); captureError( diff --git a/apps/backend/src/lib/external-db-sync-metadata.ts b/apps/backend/src/lib/external-db-sync-metadata.ts index dd64cb039e..b037e74635 100644 --- a/apps/backend/src/lib/external-db-sync-metadata.ts +++ b/apps/backend/src/lib/external-db-sync-metadata.ts @@ -11,18 +11,25 @@ const fuseboxSelect = { pollerEnabled: true, }; +// Default values match the Prisma schema defaults +const defaultFusebox: ExternalDbSyncFusebox = { + sequencerEnabled: true, + pollerEnabled: true, +}; + export async function getExternalDbSyncFusebox(): Promise { - return await globalPrismaClient.externalDbSyncMetadata.upsert({ + const result = await globalPrismaClient.externalDbSyncMetadata.findFirst({ where: { singleton: BooleanTrue.TRUE }, - create: { singleton: BooleanTrue.TRUE }, - update: {}, select: fuseboxSelect, }); + // Return defaults if row doesn't exist yet (row is created on first update) + return result ?? defaultFusebox; } export async function updateExternalDbSyncFusebox( updates: ExternalDbSyncFusebox, ): Promise { + // Upsert is fine here - updates are infrequent and typically manual/admin actions return await globalPrismaClient.externalDbSyncMetadata.upsert({ where: { singleton: BooleanTrue.TRUE }, create: { singleton: BooleanTrue.TRUE, ...updates }, diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index b6e21122ce..7d1399043c 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -186,6 +186,53 @@ export async function bumpEmailAddress(options: { unindexed?: boolean } = {}) { return mailbox; } +// Type for outbox email items (simplified - full type is EmailOutboxCrud["Server"]["Read"]) +type OutboxEmail = { + id: string, + subject?: string, + status: string, + simple_status: string, + to?: { + type: string, + user_id?: string, + [key: string]: unknown, + }, + [key: string]: unknown, +}; + +// Helper to get emails from the outbox, filtered by subject if provided +export async function getOutboxEmails(options?: { subject?: string }): Promise { + const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { + method: "GET", + accessType: "server", + }); + const items = listResponse.body.items as OutboxEmail[]; + if (options?.subject) { + return items.filter((e) => e.subject === options.subject); + } + return items; +} + +// Helper to poll the outbox until the most recent email with the expected subject has the expected status. +// Note: emails are returned ordered by createdAt desc (newest first), so we check emails[0] specifically +// to ensure we're waiting for the MOST RECENT email, not an older one with the same subject. +export async function waitForOutboxEmailWithStatus(subject: string, status: string): Promise { + const maxRetries = 24; + let emails: OutboxEmail[] = []; + for (let i = 0; i < maxRetries; i++) { + emails = await getOutboxEmails({ subject }); + // Check the most recent email (first in the list due to createdAt desc ordering) + if (emails.length > 0 && emails[0].status === status) { + return emails; + } + await wait(500); + } + throw new StackAssertionError( + `Timeout waiting for outbox email with subject "${subject}" and status "${status}"`, + { foundEmails: emails } + ); +} + export namespace Auth { export async function fastSignUp(body: any = {}) { const { userId } = await User.create(body); @@ -405,7 +452,11 @@ export namespace Auth { } await wait(100 + i * 20); if (i >= 30) { - throw new StackAssertionError(`Sign-in code message not found after ${i} attempts`, { response, messages: messages.map(m => ({ ...m, body: m.body && omit(m.body, ["html"]) })) }); + throw new StackAssertionError(`Sign-in code message not found after ${i} attempts`, { + response, + messages: messages.map(m => ({ ...m, body: m.body && omit(m.body, ["html"]) })), + outboxEmails: await getOutboxEmails(), + }); } } return { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-helpers.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-helpers.ts index 9cbba4b924..4fd8b724d8 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-helpers.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-helpers.ts @@ -1,18 +1 @@ -import { niceBackendFetch } from "../../../../backend-helpers"; - -/** - * Helper to get emails from the outbox, filtered by subject if provided. - * Shared across email test files to avoid duplication. - */ -export async function getOutboxEmails(options?: { subject?: string }) { - const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { - method: "GET", - accessType: "server", - }); - if (options?.subject) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return listResponse.body.items.filter((e: any) => e.subject === options.subject); - } - return listResponse.body.items; -} - +export { getOutboxEmails, waitForOutboxEmailWithStatus } from "../../../../backend-helpers"; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts index e343f06296..2bb050fd4f 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts @@ -1,49 +1,10 @@ -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent, nicify } from "@stackframe/stack-shared/dist/utils/strings"; import beautify from "js-beautify"; import { describe } from "vitest"; import { it, logIfTestFails } from "../../../../../helpers"; import { withPortPrefix } from "../../../../../helpers/ports"; -import { Auth, Project, User, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../../backend-helpers"; - -type OutboxEmail = { - id: string, - subject?: string, - status: string, - skipped_reason?: string, - is_transactional?: boolean, - tsx_source?: string, -}; - -// Helper to get emails from the outbox, filtered by subject if provided -async function getOutboxEmails(options?: { subject?: string }): Promise { - const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { - method: "GET", - accessType: "server", - }); - if (options?.subject) { - return listResponse.body.items.filter((e: any) => e.subject === options.subject); - } - return listResponse.body.items; -} - -// Helper to poll the outbox until an email with the expected subject and status appears -async function waitForOutboxEmailWithStatus(subject: string, status: string): Promise { - const maxRetries = 20; - let emails: OutboxEmail[] = []; - for (let i = 0; i < maxRetries; i++) { - emails = await getOutboxEmails({ subject }); - if (emails.length > 0 && emails[0].status === status) { - return emails; - } - await wait(500); - } - throw new StackAssertionError( - `Timeout waiting for outbox email with subject "${subject}" and status "${status}"`, - { foundEmails: emails } - ); -} +import { Auth, Project, User, backendContext, bumpEmailAddress, getOutboxEmails, niceBackendFetch, waitForOutboxEmailWithStatus } from "../../../../backend-helpers"; const testEmailConfig = { type: "standard", @@ -136,19 +97,15 @@ describe("email queue edge cases", () => { }); expect(deleteResponse.status).toBe(200); - // Wait for email processing - await wait(10_000); + // Poll until outbox shows SKIPPED with USER_ACCOUNT_DELETED + const outboxEmails = await waitForOutboxEmailWithStatus("Slow Render Test Email", "skipped"); + expect(outboxEmails.length).toBe(1); + expect(outboxEmails[0].skipped_reason).toBe("USER_ACCOUNT_DELETED"); // Verify no email was received (user was deleted) const messages = await mailbox.fetchMessages(); const testEmails = messages.filter(m => m.subject === "Slow Render Test Email"); expect(testEmails).toHaveLength(0); - - // Verify outbox shows SKIPPED with USER_ACCOUNT_DELETED - const outboxEmails = await getOutboxEmails({ subject: "Slow Render Test Email" }); - expect(outboxEmails.length).toBe(1); - expect(outboxEmails[0].status).toBe("skipped"); - expect(outboxEmails[0].skipped_reason).toBe("USER_ACCOUNT_DELETED"); }); it("should skip email when user removes primary email after email is queued", async ({ expect }) => { @@ -207,19 +164,15 @@ describe("email queue edge cases", () => { }); expect(deleteChannelResponse.status).toBe(200); - // Wait for email processing to complete (rendering + sending) - await wait(10_000); + // Poll until outbox shows SKIPPED with USER_HAS_NO_PRIMARY_EMAIL + const outboxEmails = await waitForOutboxEmailWithStatus("Slow Render Test Email", "skipped"); + expect(outboxEmails.length).toBe(1); + expect(outboxEmails[0].skipped_reason).toBe("USER_HAS_NO_PRIMARY_EMAIL"); // Verify no email with our subject was received (primary email was removed before sending) const messages = await mailbox.fetchMessages(); const testEmails = messages.filter(m => m.subject === "Slow Render Test Email"); expect(testEmails).toHaveLength(0); - - // Verify outbox shows SKIPPED with USER_HAS_NO_PRIMARY_EMAIL - const outboxEmails = await getOutboxEmails({ subject: "Slow Render Test Email" }); - expect(outboxEmails.length).toBe(1); - expect(outboxEmails[0].status).toBe("skipped"); - expect(outboxEmails[0].skipped_reason).toBe("USER_HAS_NO_PRIMARY_EMAIL"); }); it("should skip email when user unsubscribes after email is queued", async ({ expect }) => { @@ -275,19 +228,15 @@ describe("email queue edge cases", () => { ); expect(unsubscribeResponse.status).toBe(200); - // Wait for email processing - await wait(10_000); + // Poll until outbox shows SKIPPED with USER_UNSUBSCRIBED + const outboxEmails = await waitForOutboxEmailWithStatus("Slow Render Test Email", "skipped"); + expect(outboxEmails.length).toBe(1); + expect(outboxEmails[0].skipped_reason).toBe("USER_UNSUBSCRIBED"); // Verify no email with our subject was received (user unsubscribed) const messages = await backendContext.value.mailbox.fetchMessages(); const testEmails = messages.filter(m => m.subject === "Slow Render Test Email"); expect(testEmails).toHaveLength(0); - - // Verify outbox shows SKIPPED with USER_UNSUBSCRIBED - const outboxEmails = await getOutboxEmails({ subject: "Slow Render Test Email" }); - expect(outboxEmails.length).toBe(1); - expect(outboxEmails[0].status).toBe("skipped"); - expect(outboxEmails[0].skipped_reason).toBe("USER_UNSUBSCRIBED"); }); it("should NOT skip transactional email even when user unsubscribes from marketing", async ({ expect }) => { @@ -1399,10 +1348,7 @@ describe("theme and template deletion after scheduling", () => { // For a proper test, we'd need to pause the email, but the send-email endpoint // doesn't support is_paused directly. - // Wait for email processing - await wait(5_000); - - // Verify the email was sent successfully + // Poll until email is received (waitForMessagesWithSubject already does polling) const messages = await mailbox.waitForMessagesWithSubject("Theme Fallback Test Email"); expect(messages.length).toBeGreaterThanOrEqual(1); @@ -1484,10 +1430,7 @@ describe("theme and template deletion after scheduling", () => { } } - // Wait for email processing - await wait(5_000); - - // Verify the email was sent successfully + // Poll until email is received (waitForMessagesWithSubject already does polling) const messages = await mailbox.waitForMessagesWithSubject("Theme Fallback Test Email"); expect(messages.length).toBeGreaterThanOrEqual(1); @@ -1574,10 +1517,7 @@ describe("theme and template deletion after scheduling", () => { // that the architecture is designed to handle template deletion safely // because the source is copied to the outbox at scheduling time. - // Wait for email processing - await wait(5_000); - - // Verify the email was sent successfully + // Poll until email is received (waitForMessagesWithSubject already does polling) const messages = await mailbox.waitForMessagesWithSubject("Template Deletion Test Email"); expect(messages.length).toBeGreaterThanOrEqual(1); expect(messages[0].body?.html).toContain("Content from template that will be deleted"); @@ -1664,10 +1604,7 @@ describe("theme and template deletion after scheduling", () => { }); expect(sendResponse.status).toBe(200); - // Wait for email processing - await wait(5_000); - - // Verify the email was sent successfully with the custom theme + // Poll until email is received (waitForMessagesWithSubject already does polling) const messages = await mailbox.waitForMessagesWithSubject("Custom Theme Baseline Test Email"); expect(messages.length).toBeGreaterThanOrEqual(1); @@ -1816,7 +1753,7 @@ describe("email outbox pagination", () => { expect(response.status).toBe(400); }); - it("should order emails with finishedSendingAt first (nulls last)", async ({ expect }) => { + it("should order emails by createdAt descending (newest first)", async ({ expect }) => { await Project.createAndSwitch({ display_name: "Test Ordering Project", config: { @@ -1824,16 +1761,9 @@ describe("email outbox pagination", () => { }, }); - // Create a slow-rendering draft (so we have time to pause it) const templateSource = deindent` import { Container } from "@react-email/components"; - import { Subject, NotificationCategory, Props } from "@stackframe/emails"; - - // Artificial delay to make the email slow to render - const startTime = performance.now(); - while (performance.now() - startTime < 200) { - // Busy wait - } + import { Subject, NotificationCategory } from "@stackframe/emails"; export function EmailTemplate({ user, project }) { return ( @@ -1871,8 +1801,8 @@ describe("email outbox pagination", () => { expect(createUserResponse.status).toBe(201); const userId = createUserResponse.body.id; - // Send 2 emails to the user and wait for them to be sent - for (let i = 0; i < 2; i++) { + // Send 3 emails sequentially (need distinct timestamps for ordering test) + for (let i = 0; i < 3; i++) { const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", { method: "POST", accessType: "server", @@ -1884,35 +1814,35 @@ describe("email outbox pagination", () => { expect(sendResponse.status).toBe(200); } - // Wait for email processing - they should be sent - await wait(5_000); + // Poll until all 3 emails appear in outbox (wait up to 12s, matching waitForOutboxEmailWithStatus) + const maxAttempts = 24; + const pollInterval = 500; + let emails: Array<{ subject?: string, created_at_millis: number }> = []; - // Verify at least one email was sent - const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { - method: "GET", - accessType: "server", - }); - expect(listResponse.status).toBe(200); - const emails = listResponse.body.items.filter((e: { subject?: string }) => - e.subject === "Ordering Test Email" - ); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { + method: "GET", + accessType: "server", + }); + expect(listResponse.status).toBe(200); - // Check ordering: finished emails should come before non-finished ones - // Statuses that have finishedSendingAt set (email has completed processing) - const finishedStatuses = new Set(["sent", "opened", "clicked", "marked-as-spam", "server-error", "bounced", "delivery-delayed", "skipped"]); - let foundNonFinished = false; - for (const email of emails) { - const hasFinished = finishedStatuses.has(email.status); - if (!hasFinished) { - foundNonFinished = true; - } else if (foundNonFinished) { - // We found a finished email after a non-finished email - wrong ordering - expect.fail(`Wrong ordering: found '${email.status}' after non-finished emails`); - } + emails = listResponse.body.items.filter((e: { subject?: string }) => + e.subject === "Ordering Test Email" + ); + + if (emails.length >= 3) break; + await wait(pollInterval); } - // We should have at least one finished email - const hasSentEmails = emails.some((e: { status: string }) => finishedStatuses.has(e.status)); - expect(hasSentEmails).toBe(true); - }); + + // Verify we have our emails + expect(emails.length).toBeGreaterThanOrEqual(3); + + // Check ordering: emails should be ordered by createdAt descending (newest first) + for (let i = 0; i < emails.length - 1; i++) { + const current = emails[i]; + const next = emails[i + 1]; + expect(current.created_at_millis).toBeGreaterThanOrEqual(next.created_at_millis); + } + }, 60_000); }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts index c01855d3a5..ff31fa42db 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts @@ -1,9 +1,10 @@ +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { describe } from "vitest"; import { it } from "../../../../../helpers"; import { withPortPrefix } from "../../../../../helpers/ports"; -import { Project, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../../backend-helpers"; +import { Project, backendContext, bumpEmailAddress, getOutboxEmails, niceBackendFetch, waitForOutboxEmailWithStatus } from "../../../../backend-helpers"; const testEmailConfig = { type: "standard", @@ -52,18 +53,6 @@ const slowTemplate = deindent` } `; -// Helper to get emails from the outbox, filtered by subject if provided -async function getOutboxEmails(options?: { subject?: string }) { - const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { - method: "GET", - accessType: "server", - }); - if (options?.subject) { - return listResponse.body.items.filter((e: any) => e.subject === options.subject); - } - return listResponse.body.items; -} - describe("email outbox API", () => { describe("list endpoint", () => { it("should list emails in the outbox", async ({ expect }) => { @@ -250,12 +239,8 @@ describe("email outbox API", () => { }, }); - // Wait for email to be processed - await wait(7_000); - - // Get the email from the list endpoint - const emails = await getOutboxEmails({ subject: "Get Test Email" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + // Wait for email to reach sent status + const emails = await waitForOutboxEmailWithStatus("Get Test Email", "sent"); const emailId = emails[0].id; // Get the email by ID @@ -317,11 +302,8 @@ describe("email outbox API", () => { }, }); - await wait(7_000); - - // Get the email ID from first project - const emails = await getOutboxEmails({ subject: "Cross Project Test Email" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + // Wait for email to reach sent status + const emails = await waitForOutboxEmailWithStatus("Cross Project Test Email", "sent"); const emailId = emails[0].id; // Create second project @@ -374,12 +356,8 @@ describe("email outbox API", () => { }, }); - // Wait for email to be sent - await wait(7_000); - - // Get the email ID - const emails = await getOutboxEmails({ subject: "Not Editable Test" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + // Wait for email to reach sent status + const emails = await waitForOutboxEmailWithStatus("Not Editable Test", "sent"); const emailId = emails[0].id; // Try to edit @@ -423,17 +401,10 @@ describe("email outbox API", () => { }, }); - // Wait for email to be processed and skipped - await wait(7_000); - - // Get the email - const emails = await getOutboxEmails({ subject: "Skipped Test" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + // Wait for email to reach skipped status + const emails = await waitForOutboxEmailWithStatus("Skipped Test", "skipped"); const email = emails[0]; - // Verify it's skipped - expect(email.status).toBe("skipped"); - // Try to edit const editResponse = await niceBackendFetch(`/api/v1/emails/outbox/${email.id}`, { method: "PATCH", @@ -479,10 +450,7 @@ describe("email outbox API", () => { }, }); - await wait(7_000); - - const emails = await getOutboxEmails({ subject: "Status Test Email" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + const emails = await waitForOutboxEmailWithStatus("Status Test Email", "sent"); const email = emails[0]; // Check discriminated union fields @@ -526,10 +494,7 @@ describe("email outbox API", () => { }, }); - await wait(7_000); - - const emails = await getOutboxEmails({ subject: "Skipped Status Test" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + const emails = await waitForOutboxEmailWithStatus("Skipped Status Test", "skipped"); const email = emails[0]; expect(email.status).toBe("skipped"); @@ -574,11 +539,8 @@ describe("email outbox API", () => { }); expect(sendResponse.status).toBe(200); - // Wait for email to be sent - await wait(7_000); - - const emails = await getOutboxEmails({ subject: "Edit TSX Test" }); - expect(emails.length).toBeGreaterThanOrEqual(1); + // Wait for email to reach sent status + const emails = await waitForOutboxEmailWithStatus("Edit TSX Test", "sent"); const emailId = emails[0].id; // For emails that are already SENT, we can't edit them @@ -640,10 +602,8 @@ describe("email outbox API", () => { expect(sendResponse.status).toBe(200); // Poll until we find the email and can pause it (with timeout) - let emailId: string | null = null; - let pauseSucceeded = false; - - for (let i = 0; i < 20; i++) { + let emailId: string; + for (let i = 0;; i++) { const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { method: "GET", accessType: "server", @@ -661,19 +621,61 @@ describe("email outbox API", () => { }, }); - if (pauseResponse.status === 200 && pauseResponse.body.status === "paused") { - pauseSucceeded = true; - break; + expect(pauseResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "created_at_millis": , + "has_delivered": false, + "has_rendered": false, + "id": "", + "is_paused": true, + "scheduled_at_millis": , + "simple_status": "in-progress", + "skip_deliverability_check": false, + "status": "paused", + "theme_id": null, + "to": { + "type": "user-primary-email", + "user_id": "", + }, + "tsx_source": deindent\` + import { Container } from "@react-email/components"; + import { Subject, NotificationCategory, Props } from "@stackframe/emails"; + + // Artificial delay to make the email slow to render + const startTime = performance.now(); + while (performance.now() - startTime < 500) { + // Busy wait - 500ms delay + } + + export function EmailTemplate({ user, project }) { + return ( + + + +
Slow email content
+
+ ); + } + \`, + "updated_at_millis": , + "variables": {}, + }, + "headers": Headers {