Skip to content

Commit c8b97cf

Browse files
E2E: partner signup (dubinc#3613)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 5668088 commit c8b97cf

5 files changed

Lines changed: 133 additions & 41 deletions

File tree

.github/workflows/playwright.yaml

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ jobs:
4949
AXIOM_TOKEN: ""
5050
AXIOM_DATASET: ""
5151

52-
RESEND_API_KEY: "xx"
52+
# RESEND_API_KEY must be unset so emails route through SMTP to MailHog
53+
SMTP_HOST: "localhost"
54+
SMTP_PORT: "1025"
55+
SMTP_USER: "smtpUser"
56+
SMTP_PASSWORD: "smtpPassword"
5357

5458
EMBEDDING_SYNC_SECRET: "xx"
5559
ANTHROPIC_API_KEY: "xx"
@@ -77,6 +81,12 @@ jobs:
7781
ports:
7882
- 3306:3306
7983

84+
mailhog:
85+
image: mailhog/mailhog:latest
86+
ports:
87+
- 1025:1025
88+
- 8025:8025
89+
8090
steps:
8191
- name: Check out code
8292
uses: actions/checkout@v4
@@ -127,23 +137,6 @@ jobs:
127137
working-directory: apps/web
128138
run: pnpm tsx playwright/seed.ts
129139

130-
# - name: Cache Next.js build
131-
# uses: actions/cache@v4
132-
# with:
133-
# path: apps/web/.next/cache
134-
# key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/web/**/*.ts', 'apps/web/**/*.tsx') }}
135-
# restore-keys: |
136-
# nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
137-
# nextjs-${{ runner.os }}-
138-
139-
# - name: Cache Turbo
140-
# uses: actions/cache@v4
141-
# with:
142-
# path: .turbo
143-
# key: turbo-${{ runner.os }}-${{ github.sha }}
144-
# restore-keys: |
145-
# turbo-${{ runner.os }}-
146-
147140
- name: Build application
148141
run: pnpm turbo build --filter=web
149142

apps/web/lib/actions/create-user-account.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { waitUntil } from "@vercel/functions";
66
import { flattenValidationErrors } from "next-safe-action";
77
import * as z from "zod/v4";
88
import { createId } from "../api/create-id";
9+
import { skipAuthThrottling } from "../api/environment";
910
import { hashPassword } from "../auth/password";
1011
import { signUpSchema } from "../zod/schemas/auth";
1112
import { throwIfAuthenticated } from "./auth/throw-if-authenticated";
@@ -30,13 +31,17 @@ export const createUserAccountAction = actionClient
3031

3132
const signupAttemptKey = `signup:attempts:${email}`;
3233

33-
const { remaining: attemptsRemaining } = await ratelimit(
34-
MAX_OTP_ATTEMPTS,
35-
OTP_LOCKOUT_DURATION,
36-
).getRemaining(signupAttemptKey);
34+
if (!skipAuthThrottling) {
35+
const { remaining: attemptsRemaining } = await ratelimit(
36+
MAX_OTP_ATTEMPTS,
37+
OTP_LOCKOUT_DURATION,
38+
).getRemaining(signupAttemptKey);
3739

38-
if (attemptsRemaining <= 0) {
39-
throw new Error("Too many failed attempts. You have to try again later.");
40+
if (attemptsRemaining <= 0) {
41+
throw new Error(
42+
"Too many failed attempts. You have to try again later.",
43+
);
44+
}
4045
}
4146

4247
const verificationToken = await prisma.emailVerificationToken.findUnique({

apps/web/playwright/README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,30 @@
88
pnpm --filter web exec playwright install chromium
99
```
1010

11-
2. Set environment variables in `apps/web/.env`:
11+
2. Start MailHog (used for email verification during signup):
1212

1313
```sh
14-
# Required for credential-based tests
15-
E2E_PARTNER_EMAIL=your-test-partner@example.com
16-
E2E_PARTNER_PASSWORD=your-test-password
14+
docker-compose -f apps/web/docker-compose.yml up -d mailhog
15+
```
16+
17+
3. Set environment variables in `apps/web/.env`:
18+
19+
```sh
20+
# Required for login tests (seeded user)
21+
E2E_PARTNER_EMAIL=partner1@dub-internal-test.com
22+
E2E_PARTNER_PASSWORD=password
23+
24+
# SMTP must point to MailHog — signup tests read OTP emails from it
25+
SMTP_HOST=localhost
26+
SMTP_PORT=1025
27+
28+
# RESEND_API_KEY must NOT be set, otherwise emails go to Resend instead of MailHog
1729

1830
# Optional — defaults to http://partners.localhost:8888
1931
PLAYWRIGHT_BASE_URL=http://partners.localhost:8888
2032
```
2133

22-
The test user must exist in your local database with a password and ideally a partner profile. Partner onboarding tests use the same credentials; the onboarding flow will create or update the partner record (no separate onboarding-only user required).
34+
The seeded test user (`E2E_PARTNER_EMAIL`) must exist in your local database — run `tsx apps/web/playwright/seed.ts` to create it. Signup tests generate a fresh user each run via MailHog email verification.
2335

2436
## Running tests
2537

apps/web/playwright/auth.setup.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,46 @@
1-
import { expect, test as setup } from "@playwright/test";
2-
import { env } from "./env";
1+
import { nanoid } from "@dub/utils";
2+
import { expect, test } from "@playwright/test";
3+
import { extractOtp, waitForEmail } from "./mailhog";
4+
5+
// Must satisfy: 8+ chars, uppercase, lowercase, digit
6+
const SIGNUP_PASSWORD = "Password123";
37

48
const authFile = "playwright/.auth/partner.json";
59

6-
setup("authenticate as partner", async ({ page }) => {
7-
await page.goto("/login");
8-
await page.locator('input[name="email"]').fill(env.E2E_PARTNER_EMAIL);
9-
await page.getByRole("button", { name: "Log in with email" }).click();
10-
await expect(page.locator('input[type="password"]')).toBeVisible();
11-
await page.locator('input[type="password"]').fill(env.E2E_PARTNER_PASSWORD);
12-
await page.getByRole("button", { name: "Log in with password" }).click();
13-
await page.waitForURL((url) =>
14-
/^\/(programs|onboarding)/.test(new URL(url).pathname),
15-
);
10+
test("sign up and verify new partner", async ({ page }) => {
11+
const email = `${nanoid(10)}@dub-internal-test.com`;
12+
13+
// Go to registration page
14+
await page.goto("/register");
15+
16+
// Step 1: Enter email and reveal password field
17+
await page.locator('input[name="email"]').fill(email);
18+
await page.getByRole("button", { name: "Sign Up" }).click();
19+
20+
// Step 2: Enter password and submit
21+
const passwordInput = page.locator('input[name="password"]');
22+
await expect(passwordInput).toBeVisible();
23+
await passwordInput.fill(SIGNUP_PASSWORD);
24+
await page.getByRole("button", { name: "Sign Up" }).click();
25+
26+
// Step 3: Verify email via OTP from MailHog
27+
await expect(
28+
page.getByRole("heading", { name: "Verify your email address" }),
29+
).toBeVisible();
30+
31+
const message = await waitForEmail(email);
32+
const otp = extractOtp(message);
33+
34+
// The OTP input auto-focuses on desktop — type the digits directly
35+
await page.keyboard.type(otp);
36+
37+
// Step 4: Wait for redirect to onboarding after auto-submit
38+
// CI is slower; use domcontentloaded so we don't wait for full page load (images, etc.)
39+
await page.waitForURL(/\/onboarding/, {
40+
timeout: process.env.CI ? 30_000 : 15_000,
41+
waitUntil: "domcontentloaded",
42+
});
43+
44+
// Save authenticated state
1645
await page.context().storageState({ path: authFile });
1746
});

apps/web/playwright/mailhog.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const MAILHOG_API = "http://localhost:8025/api";
2+
3+
interface MailHogMessage {
4+
Content: {
5+
Body: string;
6+
Headers: Record<string, string[]>;
7+
};
8+
}
9+
10+
interface MailHogSearchResponse {
11+
total: number;
12+
count: number;
13+
start: number;
14+
items: MailHogMessage[];
15+
}
16+
17+
// Poll MailHog until an email arrives for the given recipient.
18+
export async function waitForEmail(
19+
to: string,
20+
{ timeout = 30_000, interval = 1_000 } = {},
21+
): Promise<MailHogMessage> {
22+
const deadline = Date.now() + timeout;
23+
24+
while (Date.now() < deadline) {
25+
const res = await fetch(
26+
`${MAILHOG_API}/v2/search?kind=to&query=${encodeURIComponent(to)}`,
27+
);
28+
29+
if (res.ok) {
30+
const data: MailHogSearchResponse = await res.json();
31+
32+
if (data.total > 0) {
33+
return data.items[0];
34+
}
35+
}
36+
37+
await new Promise((r) => setTimeout(r, interval));
38+
}
39+
40+
throw new Error(`No email received for ${to} within ${timeout}ms`);
41+
}
42+
43+
// Extract the 6-digit OTP code from a MailHog message body.
44+
export function extractOtp(message: MailHogMessage): string {
45+
const body = message.Content.Body;
46+
const match = body.match(/\b(\d{6})\b/);
47+
48+
if (!match) {
49+
throw new Error("Could not extract OTP from email body");
50+
}
51+
52+
return match[1];
53+
}

0 commit comments

Comments
 (0)