diff --git a/.agents/skills/playwright-api-tests/SKILL.md b/.agents/skills/playwright-api-tests/SKILL.md index 63f6005cc5e..d21668ff3c7 100644 --- a/.agents/skills/playwright-api-tests/SKILL.md +++ b/.agents/skills/playwright-api-tests/SKILL.md @@ -54,10 +54,6 @@ import { expect } from "@playwright/test"; import { randomName } from "../../utils"; import { test, type ApiClient } from "../fixtures"; -test.describe.configure({ - mode: "parallel", -}); - async function createThing( api: ApiClient, overrides: Record = {}, @@ -98,7 +94,7 @@ test("POST /things", async ({ api }) => { | Rule | Detail | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Import `test` from `../fixtures` | Provides `api`, `workspace`, and `program` | -| `test.describe.configure({ mode: "parallel" })` | At top of every API spec file | +| Serial only when tests share state | API project is `fullyParallel: true`. Do not add `mode: "parallel"`. Use `test.describe.configure({ mode: "serial" })` only when tests in a file/describe share state (e.g. domains, seeded pagination) | | Cleanup in `finally` | Create → assert → always delete created rows | | Unique names/ids | Use `randomName` / `randomCustomer` / `randomPartnerEmail` from `../../utils` — never fixed colliding names | | Assert status + body | Prefer `toStrictEqual` / `toEqual` on full shapes; use `expect.any(String)` for ids/timestamps | diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index bdde5b27638..8b9d91f7174 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -15,7 +15,7 @@ jobs: e2e: permissions: contents: read - timeout-minutes: 20 + timeout-minutes: 30 runs-on: ubuntu-latest env: @@ -32,8 +32,7 @@ jobs: E2E_PARTNER_EMAIL: "partner1@dub-internal-test.com" E2E_PARTNER_PASSWORD: "password" - TINYBIRD_API_KEY: "xx" - TINYBIRD_API_URL: "xx" + # TINYBIRD_API_KEY / TINYBIRD_API_URL are set after Tinybird Local is ready # serverless-redis-http (SRH) — must match jobs.e2e.services.srh env SRH_TOKEN: "e2e_srh_token" @@ -41,10 +40,12 @@ jobs: UPSTASH_REDIS_REST_TOKEN: "e2e_srh_token" UPSTASH_VECTOR_REST_URL: "https://sensible-camel-xxxx.upstash.io" UPSTASH_VECTOR_REST_TOKEN: "xx" - QSTASH_URL: "https://qstash-us-east-1.upstash.io" - QSTASH_TOKEN: "xx" - QSTASH_CURRENT_SIGNING_KEY: "xx" - QSTASH_NEXT_SIGNING_KEY: "xx" + + # QStash local-dev (User 1) — must match jobs.e2e Start QStash step + QSTASH_URL: "http://127.0.0.1:8080" + QSTASH_TOKEN: "eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=" + QSTASH_CURRENT_SIGNING_KEY: "sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r" + QSTASH_NEXT_SIGNING_KEY: "sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs" # RESEND_API_KEY must be unset so emails route through SMTP to MailHog SMTP_HOST: "localhost" @@ -104,6 +105,14 @@ jobs: ports: - 8079:80 + # Classic .datasource/.pipe project; COMPATIBILITY_MODE is required + tinybird: + image: tinybirdco/tinybird-local:latest + env: + COMPATIBILITY_MODE: "1" + ports: + - 7181:7181 + steps: - name: Check out code uses: actions/checkout@v4 @@ -119,6 +128,52 @@ jobs: -mysql-no-pass \ -mysql-addr=127.0.0.1 + - name: Start QStash dev server + run: | + docker run -d --name qstash-dev \ + --network host \ + public.ecr.aws/upstash/qstash:latest \ + qstash dev + for i in $(seq 1 30); do + if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080 | grep -qE '^[0-9]{3}$'; then + echo "QStash is up" + exit 0 + fi + sleep 1 + done + docker logs qstash-dev + exit 1 + + - name: Configure Tinybird Local + working-directory: packages/tinybird + run: | + TOKEN="" + for i in $(seq 1 90); do + TOKEN=$(curl -sf http://127.0.0.1:7181/tokens | jq -r '.workspace_admin_token // empty' || true) + if [ -n "$TOKEN" ]; then + echo "Tinybird Local is up" + break + fi + sleep 2 + done + if [ -z "$TOKEN" ]; then + echo "Tinybird Local did not become ready on :7181" + exit 1 + fi + + echo "::add-mask::$TOKEN" + { + echo "TINYBIRD_API_KEY<> "$GITHUB_ENV" + + python3 -m venv /tmp/tinybird-cli + /tmp/tinybird-cli/bin/pip install tinybird-cli + /tmp/tinybird-cli/bin/tb --host http://127.0.0.1:7181 --token "$TOKEN" auth + /tmp/tinybird-cli/bin/tb push --force --yes datasources/*.datasource + - name: Setup pnpm uses: pnpm/action-setup@v3 @@ -157,6 +212,10 @@ jobs: - name: Build application run: pnpm turbo build --filter=web - - name: Run Playwright tests + - name: Run Playwright API tests + working-directory: apps/web + run: pnpm test:e2e --project=api + + - name: Run Playwright UI tests working-directory: apps/web - run: pnpm test:e2e + run: pnpm test:e2e --project=partners --project=workspaces --project=chromium-workspace diff --git a/apps/web/app/(ee)/api/e2e/enrollments/route.ts b/apps/web/app/(ee)/api/e2e/enrollments/route.ts deleted file mode 100644 index a9518b40c5f..00000000000 --- a/apps/web/app/(ee)/api/e2e/enrollments/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createId } from "@/lib/api/create-id"; -import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { parseRequestBody } from "@/lib/api/utils"; -import { withWorkspace } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; -import { NextResponse } from "next/server"; -import * as z from "zod/v4"; -import { assertE2EWorkspace } from "../guard"; - -const bodySchema = z.object({ - partnerId: z.string(), - createdAt: z.string().optional(), - // Set Link.leads on the partner's first program link (used by send-campaign AND conditions) - leads: z.number().int().min(0).optional(), - // Create a User + PartnerUser from the partner email so campaign emails have a recipient - createUser: z.boolean().optional(), -}); - -// PATCH /api/e2e/enrollments - Update enrollment (e.g., backdate createdAt) -export const PATCH = withWorkspace( - async ({ req, workspace }) => { - assertE2EWorkspace(workspace); - - const programId = getDefaultProgramIdOrThrow(workspace); - const { partnerId, createdAt, leads, createUser } = bodySchema.parse( - await parseRequestBody(req), - ); - - if (createUser) { - const partner = await prisma.partner.findUnique({ - where: { id: partnerId }, - select: { email: true, name: true }, - }); - - if (partner?.email) { - const user = await prisma.user.create({ - data: { - id: createId({ prefix: "user_" }), - email: partner.email, - name: partner.name, - emailVerified: new Date(), - defaultPartnerId: partnerId, - }, - }); - - await prisma.partnerUser.create({ - data: { - userId: user.id, - partnerId, - role: "owner", - notificationPreferences: { - create: {}, - }, - }, - }); - } - } - - if (typeof leads === "number") { - const link = await prisma.link.findFirst({ - where: { - partnerId, - programId, - }, - orderBy: { - id: "asc", - }, - select: { - id: true, - }, - }); - - if (link) { - await prisma.link.update({ - where: { - id: link.id, - }, - data: { - leads, - }, - }); - } - } - - const enrollment = await prisma.programEnrollment.update({ - where: { - partnerId_programId: { - partnerId, - programId, - }, - }, - data: { - ...(createdAt && { createdAt: new Date(createdAt) }), - ...(typeof leads === "number" && { totalLeads: leads }), - }, - select: { - partnerId: true, - programId: true, - createdAt: true, - totalLeads: true, - }, - }); - - return NextResponse.json(enrollment); - }, - { - requiredPermissions: ["workspaces.write"], - }, -); diff --git a/apps/web/app/(ee)/api/e2e/notification-emails/route.ts b/apps/web/app/(ee)/api/e2e/notification-emails/route.ts deleted file mode 100644 index 4100229abae..00000000000 --- a/apps/web/app/(ee)/api/e2e/notification-emails/route.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { createId } from "@/lib/api/create-id"; -import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { withWorkspace } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; -import { NextResponse } from "next/server"; -import { assertE2EWorkspace } from "../guard"; - -// GET /api/e2e/notification-emails - Find notification emails -export const GET = withWorkspace(async ({ workspace, searchParams }) => { - assertE2EWorkspace(workspace); - - const { campaignId, partnerId } = searchParams; - - const emails = await prisma.notificationEmail.findMany({ - where: { - ...(campaignId && { campaignId }), - ...(partnerId && { partnerId }), - type: "Campaign", - }, - }); - - return NextResponse.json(emails); -}); - -// POST /api/e2e/notification-emails - Create a notification email (for test setup) -export const POST = withWorkspace( - async ({ req, workspace }) => { - assertE2EWorkspace(workspace); - - const programId = getDefaultProgramIdOrThrow(workspace); - const body = await req.json(); - - const email = await prisma.notificationEmail.create({ - data: { - id: createId({ prefix: "em_" }), - type: "Campaign", - emailId: body.emailId || `e2e_${Date.now()}`, - campaignId: body.campaignId, - programId, - partnerId: body.partnerId, - recipientUserId: body.recipientUserId, - }, - }); - - return NextResponse.json(email); - }, - { - requiredPermissions: ["workspaces.write"], - }, -); - -// DELETE /api/e2e/notification-emails - Delete notification emails (cleanup) -export const DELETE = withWorkspace( - async ({ workspace, searchParams }) => { - assertE2EWorkspace(workspace); - - const { campaignId } = searchParams; - - if (!campaignId) { - return NextResponse.json( - { error: "campaignId is required" }, - { status: 400 }, - ); - } - - const result = await prisma.notificationEmail.deleteMany({ - where: { - campaignId, - type: "Campaign", - }, - }); - - return NextResponse.json({ deleted: result.count }); - }, - { - requiredPermissions: ["workspaces.write"], - }, -); diff --git a/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts b/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts deleted file mode 100644 index ee68fdda5cd..00000000000 --- a/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { parseWorkflowConfig } from "@/lib/api/workflows/parse-workflow-config"; -import { executeSendCampaignWorkflow } from "@/lib/api/workflows/send-campaign/execute"; -import { withWorkspace } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; -import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; -import { ACME_PROGRAM_ID } from "@dub/utils"; -import { NextResponse } from "next/server"; -import { assertE2EWorkspace } from "../../guard"; - -// POST /api/e2e/trigger-workflow/[workflowId] -// Executes a workflow directly with API token auth (no QStash signature needed). -export const POST = withWorkspace(async ({ workspace, params }) => { - assertE2EWorkspace(workspace); - - const { workflowId } = params; - - try { - const workflow = await prisma.workflow.findUnique({ - where: { id: workflowId, programId: ACME_PROGRAM_ID }, - }); - - if (!workflow) { - return NextResponse.json({ - message: `Workflow ${workflowId} not found. Skipping...`, - }); - } - - if (workflow.disabledAt) { - return NextResponse.json({ - message: `Workflow ${workflowId} is disabled. Skipping...`, - }); - } - - const workflowConfig = parseWorkflowConfig(workflow); - - if (workflowConfig.action.type === WORKFLOW_ACTION_TYPES.SendCampaign) { - await executeSendCampaignWorkflow({ workflow }); - } - - return NextResponse.json({ - message: `Finished executing workflow ${workflowId}.`, - }); - } catch (error) { - return handleAndReturnErrorResponse(error); - } -}); diff --git a/apps/web/app/(ee)/api/e2e/workflows/route.ts b/apps/web/app/(ee)/api/e2e/workflows/route.ts index be4e7d18dfe..ddcff81db93 100644 --- a/apps/web/app/(ee)/api/e2e/workflows/route.ts +++ b/apps/web/app/(ee)/api/e2e/workflows/route.ts @@ -4,19 +4,18 @@ import { prisma } from "@/lib/prisma"; import { NextResponse } from "next/server"; import { assertE2EWorkspace } from "../guard"; -// GET /api/e2e/workflows - Find workflow by bountyId, campaignId, or groupId +// GET /api/e2e/workflows - Find workflow by bountyId or groupId export const GET = withWorkspace(async ({ workspace, searchParams }) => { assertE2EWorkspace(workspace); const programId = getDefaultProgramIdOrThrow(workspace); - const { bountyId, campaignId, groupId } = searchParams; + const { bountyId, groupId } = searchParams; const workflow = await prisma.workflow.findFirst({ where: { programId, ...(bountyId && { bounty: { id: bountyId } }), - ...(campaignId && { campaign: { id: campaignId } }), ...(groupId && { partnerGroup: { id: groupId } }), }, select: { diff --git a/apps/web/app/(ee)/api/partners/[partnerId]/cross-program-summary/route.ts b/apps/web/app/(ee)/api/partners/[partnerId]/network-activity/route.ts similarity index 88% rename from apps/web/app/(ee)/api/partners/[partnerId]/cross-program-summary/route.ts rename to apps/web/app/(ee)/api/partners/[partnerId]/network-activity/route.ts index 59aa8ada4a0..da93b1f3672 100644 --- a/apps/web/app/(ee)/api/partners/[partnerId]/cross-program-summary/route.ts +++ b/apps/web/app/(ee)/api/partners/[partnerId]/network-activity/route.ts @@ -4,11 +4,11 @@ import { withWorkspace } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { ACTIVE_ENROLLMENT_STATUSES, - partnerCrossProgramSummarySchema, + partnerNetworkActivitySummarySchema, } from "@/lib/zod/schemas/partners"; import { NextResponse } from "next/server"; -// GET /api/partners/:partnerId/cross-program-summary - get cross-program summary for a partner +// GET /api/partners/:partnerId/network-activity - get network activity summary for a partner export const GET = withWorkspace( async ({ workspace, params }) => { const { partnerId } = params; @@ -41,7 +41,7 @@ export const GET = withWorkspace( ?._count ?? 0; return NextResponse.json( - partnerCrossProgramSummarySchema.parse({ + partnerNetworkActivitySummarySchema.parse({ totalPrograms: activePrograms + bannedPrograms, activePrograms, bannedPrograms, diff --git a/apps/web/lib/api/customers/get-customer-stripe-invoices.ts b/apps/web/lib/api/customers/get-customer-stripe-invoices.ts index 1db1f5046d8..6f3233d647a 100644 --- a/apps/web/lib/api/customers/get-customer-stripe-invoices.ts +++ b/apps/web/lib/api/customers/get-customer-stripe-invoices.ts @@ -4,6 +4,7 @@ import { stripeAppClient } from "@/lib/stripe"; import { StripeCustomerInvoiceSchema } from "@/lib/zod/schemas/customers"; import { STRIPE_INTEGRATION_ID } from "@dub/utils"; import Stripe from "stripe"; +import { DubApiError } from "../errors"; type ExpandedStripeInvoice = Stripe.Invoice & { id: string; @@ -48,20 +49,45 @@ export async function getCustomerStripeInvoices({ installedStripeIntegration.settings || {}, ); + if (!stripeCustomerId.startsWith("cus_")) { + throw new DubApiError({ + code: "bad_request", + message: `Customer has an invalid Stripe customer ID (${stripeCustomerId}). Stripe customer IDs start with "cus_".`, + }); + } + const stripe = stripeAppClient({ mode: stripeIntegrationSettings.stripeMode, }); - const { data } = await stripe.invoices.list( - { - customer: stripeCustomerId, - status: "paid", - limit: 100, - expand: ["data.payments.data.payment"], - }, - { - stripeAccount: stripeConnectId, - }, - ); + + let data: Stripe.Invoice[]; + try { + const res = await stripe.invoices.list( + { + customer: stripeCustomerId, + status: "paid", + limit: 100, + expand: ["data.payments.data.payment"], + }, + { + stripeAccount: stripeConnectId, + }, + ); + data = res.data; + } catch (error) { + if ( + error instanceof Stripe.errors.StripeError && + error.code === "resource_missing" + ) { + throw new DubApiError({ + code: "bad_request", + message: `Stripe customer "${stripeCustomerId}" was not found on the connected Stripe account. Update the customer's Stripe customer ID and try again.`, + }); + } + + throw error; + } + const invoices = data.filter( (invoice) => invoice.id, ) as ExpandedStripeInvoice[]; diff --git a/apps/web/lib/api/sales/calculate-sale-earnings.ts b/apps/web/lib/api/sales/calculate-sale-earnings.ts index b119674aceb..79a0d227a52 100644 --- a/apps/web/lib/api/sales/calculate-sale-earnings.ts +++ b/apps/web/lib/api/sales/calculate-sale-earnings.ts @@ -21,7 +21,7 @@ export const calculateSaleEarnings = ({ if (reward.type === "flat") { return sale.quantity * amount; } else if (reward.type === "percentage") { - return sale.amount * (amount / 100); + return Math.round((sale.amount * amount) / 100); } return 0; diff --git a/apps/web/lib/swr/use-partner-cross-program-summary.ts b/apps/web/lib/swr/use-partner-cross-program-summary.ts deleted file mode 100644 index 80c473b23f1..00000000000 --- a/apps/web/lib/swr/use-partner-cross-program-summary.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { partnerCrossProgramSummarySchema } from "@/lib/zod/schemas/partners"; -import { fetcher } from "@dub/utils"; -import useSWR from "swr"; -import * as z from "zod/v4"; -import useWorkspace from "./use-workspace"; - -type CrossProgramSummary = z.infer; - -export function usePartnerCrossProgramSummary({ - partnerId, - enabled = true, -}: { - partnerId: string | null | undefined; - enabled?: boolean; -}) { - const { id: workspaceId } = useWorkspace(); - - const { data, isLoading, error } = useSWR( - enabled && partnerId && workspaceId - ? `/api/partners/${partnerId}/cross-program-summary?workspaceId=${workspaceId}` - : null, - fetcher, - { - revalidateOnMount: true, - }, - ); - - return { - crossProgramSummary: data, - isLoading, - error, - }; -} diff --git a/apps/web/lib/tapfiliate/update-stripe-customers.ts b/apps/web/lib/tapfiliate/update-stripe-customers.ts index dca4328478c..315f9639e9c 100644 --- a/apps/web/lib/tapfiliate/update-stripe-customers.ts +++ b/apps/web/lib/tapfiliate/update-stripe-customers.ts @@ -34,8 +34,13 @@ export async function updateStripeCustomers(payload: TapfiliateImportPayload) { if (!workspace.stripeConnectId) { console.error( - `Workspace ${workspace.id} has no stripeConnectId. Skipping...`, + `Workspace ${workspace.id} has no stripeConnectId. Skipping Stripe customer matching...`, ); + + await tapfiliateImporter.queue({ + ...payload, + action: "cleanup-partners", + }); return; } diff --git a/apps/web/lib/tolt/update-stripe-customers.ts b/apps/web/lib/tolt/update-stripe-customers.ts index e2d239ea77a..8e553ee3d07 100644 --- a/apps/web/lib/tolt/update-stripe-customers.ts +++ b/apps/web/lib/tolt/update-stripe-customers.ts @@ -34,8 +34,13 @@ export async function updateStripeCustomers(payload: ToltImportPayload) { if (!workspace.stripeConnectId) { console.error( - `Workspace ${workspace.id} has no stripeConnectId. Skipping...`, + `Workspace ${workspace.id} has no stripeConnectId. Skipping Stripe customer matching...`, ); + + await toltImporter.queue({ + ...payload, + action: "cleanup-partners", + }); return; } diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 9096fc14533..8effe60da94 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -1052,7 +1052,7 @@ export const partnerPayoutSettingsSchema = z.object({ taxId: z.string().max(100).trim().nullish(), }); -export const partnerCrossProgramSummarySchema = z.object({ +export const partnerNetworkActivitySummarySchema = z.object({ totalPrograms: z.number(), activePrograms: z.number(), bannedPrograms: z.number(), diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 52af39eddad..19ba583d254 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -88,7 +88,10 @@ export default defineConfig({ port: 8888, reuseExistingServer: true, timeout: 120_000, + // Zod 422s (and other expected API errors) log to stderr via + // handleApiError; ignore both streams so GH Actions logs stay readable. stdout: "ignore", + stderr: "ignore", } : undefined, }); diff --git a/apps/web/playwright/api/bounties/bounties.spec.ts b/apps/web/playwright/api/bounties/bounties.spec.ts index 2dee72deefe..c55961419e3 100644 --- a/apps/web/playwright/api/bounties/bounties.spec.ts +++ b/apps/web/playwright/api/bounties/bounties.spec.ts @@ -4,13 +4,9 @@ import type { BountyProps } from "@/lib/types"; import { expect } from "@playwright/test"; import { BountyStartMode, type Program } from "@prisma/client"; import { addDays, addMonths, subDays } from "date-fns"; -import { randomName } from "../../utils"; +import { apiError, randomName } from "../../utils"; import { test, type ApiClient } from "../fixtures"; -test.describe.configure({ - mode: "parallel", -}); - type BountyJson = Omit< BountyProps, "startsAt" | "endsAt" | "submissionsOpenAt" | "socialMetricsLastSyncedAt" @@ -93,39 +89,6 @@ const expectedBountyDefaults = { partnerTags: [], }; -const unprocessable = (message: string) => ({ - status: 422, - data: { - error: { - code: "unprocessable_entity", - message, - doc_url: "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, -}); - -const badRequest = (message: string) => ({ - status: 400, - data: { - error: { - code: "bad_request", - message, - doc_url: "https://dub.co/docs/api-reference/errors#bad-request", - }, - }, -}); - -const notFound = (bountyId: string) => ({ - status: 404, - data: { - error: { - code: "not_found", - message: `Bounty ${bountyId} not found.`, - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }, -}); - test("POST /bounties", async ({ api, program }) => { let id: string | undefined; const body = bountyPayload(program); @@ -358,7 +321,10 @@ test("DELETE /bounties/{bountyId}", async ({ api, program }) => { expect(status).toEqual(200); expect(data).toStrictEqual({ id: created.id }); expect(await api.get(`/api/bounties/${created.id}`)).toEqual( - notFound(created.id), + apiError({ + code: "not_found", + message: `Bounty ${created.id} not found.`, + }), ); }); @@ -714,9 +680,11 @@ test("PATCH /bounties/{bountyId} – submissionFrequency requires endsAt on the maxSubmissions: 4, }), ).toEqual( - badRequest( - "`endsAt` or `endsAfterDays` is required when `submissionFrequency` is set.", - ), + apiError({ + code: "bad_request", + message: + "`endsAt` or `endsAfterDays` is required when `submissionFrequency` is set.", + }), ); } finally { await deleteBounty(api, id); @@ -738,7 +706,10 @@ test("PATCH /bounties/{bountyId} – submissionsOpenAt without endsAt is rejecte submissionsOpenAt: addDays(new Date(), 5).toISOString(), }), ).toEqual( - badRequest("`endsAt` is required when `submissionsOpenAt` is set."), + apiError({ + code: "bad_request", + message: "`endsAt` is required when `submissionsOpenAt` is set.", + }), ); } finally { await deleteBounty(api, id); @@ -758,9 +729,11 @@ test("PATCH /bounties/{bountyId} – maxSubmissions below minimum is rejected", expect( await api.patch(`/api/bounties/${id}`, { maxSubmissions: 1 }), ).toEqual( - unprocessable( - "too_small: maxSubmissions: If `maxSubmissions` is set, it must be at least 2", - ), + apiError({ + code: "unprocessable_entity", + message: + "too_small: maxSubmissions: If `maxSubmissions` is set, it must be at least 2", + }), ); } finally { await deleteBounty(api, id); @@ -780,9 +753,10 @@ test("PATCH /bounties/{bountyId} – maxSubmissions above maximum is rejected", expect( await api.patch(`/api/bounties/${id}`, { maxSubmissions: 51 }), ).toEqual( - unprocessable( - "too_big: maxSubmissions: Too big: expected number to be <=50", - ), + apiError({ + code: "unprocessable_entity", + message: "too_big: maxSubmissions: Too big: expected number to be <=50", + }), ); } finally { await deleteBounty(api, id); @@ -794,7 +768,12 @@ test("POST /bounties – invalid group IDs", async ({ api, program }) => { await api.post("/api/bounties", { ...bountyPayload(program, { groupIds: ["invalid-group-id"] }), }), - ).toEqual(badRequest("Invalid group IDs detected: invalid-group-id")); + ).toEqual( + apiError({ + code: "bad_request", + message: "Invalid group IDs detected: invalid-group-id", + }), + ); }); test("POST /bounties – invalid partner tag IDs", async ({ api, program }) => { @@ -805,7 +784,10 @@ test("POST /bounties – invalid partner tag IDs", async ({ api, program }) => { }), }), ).toEqual( - badRequest("Invalid partner tag IDs detected: invalid-partner-tag-id"), + apiError({ + code: "bad_request", + message: "Invalid partner tag IDs detected: invalid-partner-tag-id", + }), ); }); @@ -895,7 +877,10 @@ test("PATCH /bounties/{bountyId} – invalid partner tag IDs", async ({ partnerTagIds: ["invalid-partner-tag-id"], }), ).toEqual( - badRequest("Invalid partner tag IDs detected: invalid-partner-tag-id"), + apiError({ + code: "bad_request", + message: "Invalid partner tag IDs detected: invalid-partner-tag-id", + }), ); } finally { await deleteBounty(api, id); @@ -911,9 +896,11 @@ test("POST /bounties – maxSubmissions below minimum is rejected", async ({ ...bountyPayload(program, { maxSubmissions: 1 }), }), ).toEqual( - unprocessable( - "too_small: maxSubmissions: If `maxSubmissions` is set, it must be at least 2", - ), + apiError({ + code: "unprocessable_entity", + message: + "too_small: maxSubmissions: If `maxSubmissions` is set, it must be at least 2", + }), ); }); @@ -926,9 +913,10 @@ test("POST /bounties – maxSubmissions above maximum is rejected", async ({ ...bountyPayload(program, { maxSubmissions: 51 }), }), ).toEqual( - unprocessable( - "too_big: maxSubmissions: Too big: expected number to be <=50", - ), + apiError({ + code: "unprocessable_entity", + message: "too_big: maxSubmissions: Too big: expected number to be <=50", + }), ); }); @@ -948,9 +936,10 @@ test("POST /bounties – submissionFrequency without maxSubmissions is rejected" }), }), ).toEqual( - badRequest( - "`maxSubmissions` is required when `submissionFrequency` is set.", - ), + apiError({ + code: "bad_request", + message: "`maxSubmissions` is required when `submissionFrequency` is set.", + }), ); }); @@ -967,9 +956,11 @@ test("POST /bounties – submissionFrequency without endsAt is rejected", async }), }), ).toEqual( - badRequest( - "`endsAt` or `endsAfterDays` is required when `submissionFrequency` is set.", - ), + apiError({ + code: "bad_request", + message: + "`endsAt` or `endsAfterDays` is required when `submissionFrequency` is set.", + }), ); }); @@ -988,7 +979,10 @@ test("POST /bounties – submissionsOpenAt without endsAt is rejected", async ({ }), }), ).toEqual( - badRequest("`endsAt` is required when `submissionsOpenAt` is set."), + apiError({ + code: "bad_request", + message: "`endsAt` is required when `submissionsOpenAt` is set.", + }), ); }); @@ -1007,7 +1001,12 @@ test("POST /bounties – submissionsOpenAt before startsAt is rejected", async ( submissionsOpenAt: subDays(new Date(startsAt), 1).toISOString(), }), }), - ).toEqual(badRequest("`submissionsOpenAt` must be on or after `startsAt`.")); + ).toEqual( + apiError({ + code: "bad_request", + message: "`submissionsOpenAt` must be on or after `startsAt`.", + }), + ); }); test("POST /bounties – submissionsOpenAt after endsAt is rejected", async ({ @@ -1025,7 +1024,12 @@ test("POST /bounties – submissionsOpenAt after endsAt is rejected", async ({ submissionsOpenAt: addDays(new Date(endsAt), 1).toISOString(), }), }), - ).toEqual(badRequest("`submissionsOpenAt` must be on or before `endsAt`.")); + ).toEqual( + apiError({ + code: "bad_request", + message: "`submissionsOpenAt` must be on or before `endsAt`.", + }), + ); }); test("POST /bounties – relative with startsAt is rejected", async ({ @@ -1041,9 +1045,10 @@ test("POST /bounties – relative with startsAt is rejected", async ({ }), }), ).toEqual( - badRequest( - "`startsAt` is not supported when the `startMode` is `relative`.", - ), + apiError({ + code: "bad_request", + message: "`startsAt` is not supported when the `startMode` is `relative`.", + }), ); }); @@ -1061,7 +1066,10 @@ test("POST /bounties – both endsAt and endsAfterDays is rejected", async ({ }), }), ).toEqual( - badRequest("Bounties cannot have both `endsAt` and `endsAfterDays`."), + apiError({ + code: "bad_request", + message: "Bounties cannot have both `endsAt` and `endsAfterDays`.", + }), ); }); @@ -1069,18 +1077,29 @@ const unknownBountyId = "bnty_does_not_exist"; test("GET /bounties/{bountyId} – not found", async ({ api }) => { expect(await api.get(`/api/bounties/${unknownBountyId}`)).toEqual( - notFound(unknownBountyId), + apiError({ + code: "not_found", + message: `Bounty ${unknownBountyId} not found.`, + }), ); }); test("PATCH /bounties/{bountyId} – not found", async ({ api }) => { expect( await api.patch(`/api/bounties/${unknownBountyId}`, { name: "x" }), - ).toEqual(notFound(unknownBountyId)); + ).toEqual( + apiError({ + code: "not_found", + message: `Bounty ${unknownBountyId} not found.`, + }), + ); }); test("DELETE /bounties/{bountyId} – not found", async ({ api }) => { expect(await api.delete(`/api/bounties/${unknownBountyId}`)).toEqual( - notFound(unknownBountyId), + apiError({ + code: "not_found", + message: `Bounty ${unknownBountyId} not found.`, + }), ); }); diff --git a/apps/web/playwright/api/campaigns/campaigns.spec.ts b/apps/web/playwright/api/campaigns/campaigns.spec.ts new file mode 100644 index 00000000000..1dc13870860 --- /dev/null +++ b/apps/web/playwright/api/campaigns/campaigns.spec.ts @@ -0,0 +1,695 @@ +import { DEFAULT_CAMPAIGN_BODY } from "@/lib/api/campaigns/constants"; +import { EMAIL_TEMPLATE_VARIABLES } from "@/lib/zod/schemas/campaigns"; +import { expect } from "@playwright/test"; +import type { CampaignType } from "@prisma/client"; +import { apiError, randomName } from "../../utils"; +import { test, type ApiClient } from "../fixtures"; +import { + campaignContent, + createCampaign, + createPartnerTag, + defaultTransactionalTriggers, + deleteCampaign, + deletePartnerTag, + mentionBodyJson, + multipleTriggerConditions, + type CampaignJson, +} from "./helpers"; + +function defaultCampaign(type: CampaignType) { + return { + id: expect.any(String), + name: "Untitled", + subject: "", + preview: null, + from: null, + bodyJson: DEFAULT_CAMPAIGN_BODY, + type, + status: "draft", + triggerConditions: + type === "transactional" ? [...defaultTransactionalTriggers] : null, + groups: [], + partnerTags: [], + scheduledAt: null, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }; +} + +async function createDraft( + api: ApiClient, + type: CampaignType = "transactional", +) { + const { status, data } = await createCampaign(api, type); + expect(status).toEqual(201); + return data.id; +} + +test("POST /campaigns – transactional", async ({ api }) => { + let id: string | undefined; + + try { + const { status, data } = await api.post<{ id: string }>("/api/campaigns", { + type: "transactional", + }); + id = data.id; + + expect(status).toEqual(201); + expect(data).toStrictEqual({ + id: expect.any(String), + }); + + const { status: getStatus, data: campaign } = await api.get( + `/api/campaigns/${id}`, + ); + + expect(getStatus).toEqual(200); + expect(campaign).toStrictEqual(defaultCampaign("transactional")); + } finally { + await deleteCampaign(api, id); + } +}); + +test("POST /campaigns – marketing", async ({ api }) => { + let id: string | undefined; + + try { + const { status, data } = await createCampaign(api, "marketing"); + id = data.id; + + expect(status).toEqual(201); + expect(data).toStrictEqual({ + id: expect.any(String), + }); + + const { data: campaign } = await api.get( + `/api/campaigns/${id}`, + ); + + expect(campaign).toStrictEqual(defaultCampaign("marketing")); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – update transactional content", async ({ + api, + program, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + + const triggerConditions = [ + { + attribute: "totalConversions", + operator: "gte", + value: 50, + }, + ] as const; + + const body = campaignContent({ + triggerConditions, + groupIds: [program.defaultGroupId], + }); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + body, + ); + + expect(status).toEqual(200); + expect(data).toStrictEqual({ + ...defaultCampaign("transactional"), + id, + name: body.name, + subject: body.subject, + bodyJson: body.bodyJson, + triggerConditions, + groups: [{ id: program.defaultGroupId }], + }); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – update marketing content", async ({ + api, + program, +}) => { + let id: string | undefined; + const scheduledAt = "2026-12-01T00:00:00.000Z"; + + try { + id = await createDraft(api, "marketing"); + const body = campaignContent({ + groupIds: [program.defaultGroupId], + scheduledAt, + triggerConditions: [...multipleTriggerConditions], + }); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + body, + ); + + expect(status).toEqual(200); + expect(data).toMatchObject({ + id, + type: "marketing", + name: body.name, + subject: body.subject, + bodyJson: body.bodyJson, + triggerConditions: null, + groups: [{ id: program.defaultGroupId }], + partnerTags: [], + }); + expect(data.scheduledAt).toEqual(scheduledAt); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – transactional ignores scheduledAt", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + + const { data } = await api.patch(`/api/campaigns/${id}`, { + scheduledAt: "2026-12-01T00:00:00.000Z", + }); + + expect(data.scheduledAt).toBeNull(); + } finally { + await deleteCampaign(api, id); + } +}); + +test("GET /campaigns/:id", async ({ api }) => { + let id: string | undefined; + + try { + id = await createDraft(api); + + const { status, data } = await api.get( + `/api/campaigns/${id}`, + ); + + expect(status).toEqual(200); + expect(data).toStrictEqual({ + ...defaultCampaign("transactional"), + id, + }); + } finally { + await deleteCampaign(api, id); + } +}); + +test("GET /campaigns – list by search", async ({ api }) => { + let id: string | undefined; + const name = randomName("campaign"); + + try { + id = await createDraft(api); + await api.patch(`/api/campaigns/${id}`, { name }); + + const { status, data: campaigns } = await api.get( + `/api/campaigns?search=${encodeURIComponent(name)}`, + ); + + expect(status).toEqual(200); + + const { data: fetched } = await api.get( + `/api/campaigns/${id}`, + ); + + expect(campaigns.find((campaign) => campaign.id === id)).toStrictEqual( + fetched, + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – transactional status draft → active → paused → active", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + + const published = await api.patch(`/api/campaigns/${id}`, { + status: "active", + }); + expect(published.status).toEqual(200); + expect(published.data.status).toEqual("active"); + + const paused = await api.patch(`/api/campaigns/${id}`, { + status: "paused", + }); + expect(paused.status).toEqual(200); + expect(paused.data.status).toEqual("paused"); + + const resumed = await api.patch(`/api/campaigns/${id}`, { + status: "active", + }); + expect(resumed.status).toEqual(200); + expect(resumed.data.status).toEqual("active"); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – marketing status draft → scheduled → canceled", async ({ + api, +}) => { + let id: string | undefined; + const scheduledAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + try { + id = await createDraft(api, "marketing"); + + const scheduled = await api.patch(`/api/campaigns/${id}`, { + status: "scheduled", + scheduledAt, + }); + expect(scheduled.status).toEqual(200); + expect(scheduled.data.status).toEqual("scheduled"); + + const canceled = await api.patch(`/api/campaigns/${id}`, { + status: "canceled", + }); + expect(canceled.status).toEqual(200); + expect(canceled.data.status).toEqual("canceled"); + } finally { + await deleteCampaign(api, id); + } +}); + +test("POST /campaigns/:id/duplicate – transactional", async ({ + api, + program, +}) => { + let id: string | undefined; + let duplicateId: string | undefined; + let partnerTagId: string | undefined; + + const bodyJson = mentionBodyJson(EMAIL_TEMPLATE_VARIABLES); + const triggerConditions = [...multipleTriggerConditions]; + + try { + const partnerTag = await createPartnerTag(program.id); + partnerTagId = partnerTag.id; + + const body = campaignContent({ + triggerConditions, + groupIds: [program.defaultGroupId], + partnerTagIds: [partnerTag.id], + bodyJson, + }); + + id = await createDraft(api); + await api.patch(`/api/campaigns/${id}`, body); + + const { status, data } = await api.post<{ id: string }>( + `/api/campaigns/${id}/duplicate`, + ); + duplicateId = data.id; + + expect(status).toEqual(200); + expect(data).toStrictEqual({ + id: expect.any(String), + }); + + const { data: duplicated } = await api.get( + `/api/campaigns/${duplicateId}`, + ); + + expect(duplicated).toStrictEqual({ + ...defaultCampaign("transactional"), + id: duplicateId, + name: `${body.name} (copy)`, + subject: body.subject, + bodyJson, + triggerConditions, + groups: [{ id: program.defaultGroupId }], + partnerTags: [{ id: partnerTag.id }], + status: "draft", + }); + } finally { + await deleteCampaign(api, duplicateId); + await deleteCampaign(api, id); + await deletePartnerTag(partnerTagId); + } +}); + +test("POST /campaigns/:id/duplicate – marketing", async ({ api }) => { + let id: string | undefined; + let duplicateId: string | undefined; + const body = campaignContent(); + + try { + id = await createDraft(api, "marketing"); + await api.patch(`/api/campaigns/${id}`, body); + + const { status, data } = await api.post<{ id: string }>( + `/api/campaigns/${id}/duplicate`, + ); + duplicateId = data.id; + + expect(status).toEqual(200); + + const { data: duplicated } = await api.get( + `/api/campaigns/${duplicateId}`, + ); + + expect(duplicated).toMatchObject({ + id: duplicateId, + type: "marketing", + name: `${body.name} (copy)`, + subject: body.subject, + bodyJson: body.bodyJson, + triggerConditions: null, + partnerTags: [], + status: "draft", + }); + } finally { + await deleteCampaign(api, duplicateId); + await deleteCampaign(api, id); + } +}); + +test("DELETE /campaigns/:id", async ({ api }) => { + const id = await createDraft(api); + + const { status, data } = await api.delete<{ id: string }>( + `/api/campaigns/${id}`, + ); + + expect(status).toEqual(200); + expect(data).toStrictEqual({ id }); + expect(await api.get(`/api/campaigns/${id}`)).toEqual( + apiError({ + code: "not_found", + message: "Campaign not found.", + }), + ); +}); + +const errorCases = [ + { + name: "POST /campaigns – missing type", + body: {}, + expected: apiError({ + code: "unprocessable_entity", + message: + 'invalid_value: type: Invalid option: expected one of "marketing"|"transactional"', + }), + }, + { + name: "POST /campaigns – invalid type", + body: { type: "invalid" }, + expected: apiError({ + code: "unprocessable_entity", + message: + 'invalid_value: type: Invalid option: expected one of "marketing"|"transactional"', + }), + }, +]; + +for (const { name, body, expected } of errorCases) { + test(name, async ({ api }) => { + expect(await api.post("/api/campaigns", body)).toEqual(expected); + }); +} + +test("GET /campaigns/:id – not found", async ({ api }) => { + expect(await api.get("/api/campaigns/cmp_does_not_exist")).toEqual( + apiError({ + code: "not_found", + message: "Campaign not found.", + }), + ); +}); + +test("PATCH /campaigns/:id – marketing draft cannot become active", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api, "marketing"); + expect( + await api.patch(`/api/campaigns/${id}`, { status: "active" }), + ).toEqual( + apiError({ + code: "bad_request", + message: "A draft campaign can't be moved to active.", + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – transactional draft cannot become scheduled", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + expect( + await api.patch(`/api/campaigns/${id}`, { status: "scheduled" }), + ).toEqual( + apiError({ + code: "bad_request", + message: "A draft campaign can't be moved to scheduled.", + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – with valid partnerTagIds", async ({ + api, + program, +}) => { + let id: string | undefined; + let partnerTagId: string | undefined; + + try { + const partnerTag = await createPartnerTag(program.id); + partnerTagId = partnerTag.id; + id = await createDraft(api); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + { + groupIds: [program.defaultGroupId], + partnerTagIds: [partnerTag.id], + }, + ); + + expect(status).toEqual(200); + expect(data.groups).toEqual([{ id: program.defaultGroupId }]); + expect(data.partnerTags).toEqual([{ id: partnerTag.id }]); + } finally { + await deleteCampaign(api, id); + await deletePartnerTag(partnerTagId); + } +}); + +test("PATCH /campaigns/:id – clear partner tags", async ({ api, program }) => { + let id: string | undefined; + let partnerTagId: string | undefined; + + try { + const partnerTag = await createPartnerTag(program.id); + partnerTagId = partnerTag.id; + id = await createDraft(api); + + const { data: withTags } = await api.patch( + `/api/campaigns/${id}`, + { partnerTagIds: [partnerTag.id] }, + ); + expect(withTags.partnerTags).toEqual([{ id: partnerTag.id }]); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + { partnerTagIds: null }, + ); + + expect(status).toEqual(200); + expect(data.partnerTags).toEqual([]); + } finally { + await deleteCampaign(api, id); + await deletePartnerTag(partnerTagId); + } +}); + +test("PATCH /campaigns/:id – invalid partner tag IDs", async ({ api }) => { + let id: string | undefined; + + try { + id = await createDraft(api); + expect( + await api.patch(`/api/campaigns/${id}`, { + partnerTagIds: ["invalid-partner-tag-id"], + }), + ).toEqual( + apiError({ + code: "bad_request", + message: "Invalid partner tag IDs detected: invalid-partner-tag-id", + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – multiple trigger conditions", async ({ api }) => { + let id: string | undefined; + const triggerConditions = [...multipleTriggerConditions]; + + try { + id = await createDraft(api); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + { triggerConditions }, + ); + + expect(status).toEqual(200); + expect(data.triggerConditions).toEqual(triggerConditions); + + const { data: fetched } = await api.get( + `/api/campaigns/${id}`, + ); + expect(fetched.triggerConditions).toEqual(triggerConditions); + + const { status: listStatus, data: campaigns } = await api.get< + CampaignJson[] + >( + `/api/campaigns?triggerConditions=${encodeURIComponent(JSON.stringify(triggerConditions))}`, + ); + + expect(listStatus).toEqual(200); + expect(campaigns.find((campaign) => campaign.id === id)).toMatchObject({ + id, + triggerConditions, + }); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – duplicate trigger condition attribute", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + expect( + await api.patch(`/api/campaigns/${id}`, { + triggerConditions: [ + { attribute: "totalConversions", operator: "gte", value: 50 }, + { attribute: "totalConversions", operator: "lte", value: 100 }, + ], + }), + ).toEqual( + apiError({ + code: "bad_request", + message: "Each activity can only be used once in the campaign logic.", + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – exclusive trigger condition cannot mix", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + expect( + await api.patch(`/api/campaigns/${id}`, { + triggerConditions: [ + { attribute: "partnerJoined", operator: "gte", value: 0 }, + { attribute: "totalConversions", operator: "gte", value: 50 }, + ], + }), + ).toEqual( + apiError({ + code: "bad_request", + message: + 'Campaign logic with "joins the program" cannot include other conditions.', + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – triggerConditions must be an array", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + expect( + await api.patch(`/api/campaigns/${id}`, { + triggerConditions: { + attribute: "totalConversions", + operator: "gte", + value: 50, + }, + }), + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: + "invalid_type: triggerConditions: Invalid input: expected array, received object", + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – email template variables in bodyJson", async ({ + api, +}) => { + let id: string | undefined; + const bodyJson = mentionBodyJson(EMAIL_TEMPLATE_VARIABLES); + + try { + id = await createDraft(api); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + { bodyJson }, + ); + + expect(status).toEqual(200); + expect(data.bodyJson).toEqual(bodyJson); + + const { data: fetched } = await api.get( + `/api/campaigns/${id}`, + ); + expect(fetched.bodyJson).toEqual(bodyJson); + } finally { + await deleteCampaign(api, id); + } +}); diff --git a/apps/web/playwright/api/campaigns/helpers.ts b/apps/web/playwright/api/campaigns/helpers.ts new file mode 100644 index 00000000000..385fd4ca207 --- /dev/null +++ b/apps/web/playwright/api/campaigns/helpers.ts @@ -0,0 +1,95 @@ +import { createId } from "@/lib/api/create-id"; +import { prisma } from "@/lib/prisma"; +import type { Campaign } from "@/lib/types"; +import type { CampaignType } from "@prisma/client"; +import { randomName } from "../../utils"; +import type { ApiClient } from "../fixtures"; + +export type CampaignJson = Omit< + Campaign, + "scheduledAt" | "createdAt" | "updatedAt" +> & { + scheduledAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export const defaultTransactionalTriggers = [ + { + attribute: "partnerJoined", + operator: "gte", + value: 0, + }, +] as const; + +export const multipleTriggerConditions = [ + { + attribute: "totalConversions", + operator: "gte", + value: 50, + }, + { + attribute: "totalLeads", + operator: "gte", + value: 10, + }, +] as const; + +export function campaignContent(overrides: Record = {}) { + return { + name: randomName("campaign"), + subject: randomName("subject"), + bodyJson: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Test campaign body" }], + }, + ], + }, + ...overrides, + }; +} + +export function mentionBodyJson(ids: readonly string[]) { + return { + type: "doc", + content: [ + { + type: "paragraph", + content: ids.map((id) => ({ + type: "mention", + attrs: { id }, + })), + }, + ], + }; +} + +export async function createCampaign( + api: ApiClient, + type: CampaignType = "transactional", +) { + return api.post<{ id: string }>("/api/campaigns", { type }); +} + +export async function deleteCampaign(api: ApiClient, id: string | undefined) { + if (!id) return; + await api.delete(`/api/campaigns/${id}`); +} + +export async function createPartnerTag(programId: string) { + return prisma.partnerTag.create({ + data: { + id: createId({ prefix: "ptag_" }), + programId, + name: randomName("tag"), + }, + }); +} + +export async function deletePartnerTag(id: string | undefined) { + if (!id) return; + await prisma.partnerTag.delete({ where: { id } }); +} diff --git a/apps/web/playwright/api/campaigns/send-campaign-workflow-fixtures.ts b/apps/web/playwright/api/campaigns/send-campaign-workflow-fixtures.ts new file mode 100644 index 00000000000..8a30573ccb5 --- /dev/null +++ b/apps/web/playwright/api/campaigns/send-campaign-workflow-fixtures.ts @@ -0,0 +1,13 @@ +import { test as base } from "../fixtures"; +import { + createCampaignSession, + type CampaignSession, +} from "./send-campaign-workflow-helpers"; + +export const test = base.extend<{ campaign: CampaignSession }>({ + campaign: async ({ api, program }, use) => { + const session = createCampaignSession(api, program); + await use(session); + await session.cleanup(); + }, +}); diff --git a/apps/web/playwright/api/campaigns/send-campaign-workflow-helpers.ts b/apps/web/playwright/api/campaigns/send-campaign-workflow-helpers.ts new file mode 100644 index 00000000000..93736d86d17 --- /dev/null +++ b/apps/web/playwright/api/campaigns/send-campaign-workflow-helpers.ts @@ -0,0 +1,484 @@ +import { createId } from "@/lib/api/create-id"; +import { prisma } from "@/lib/prisma"; +import type { EnrolledPartnerProps } from "@/lib/types"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { subHours } from "date-fns"; +import { randomName, randomPartnerEmail } from "../../utils"; +import { PLAYWRIGHT_API_BASE } from "../constants"; +import type { ApiClient } from "../fixtures"; +import { + campaignContent, + createCampaign, + createPartnerTag, + deleteCampaign, + deletePartnerTag, +} from "./helpers"; + +export const enrolledDaysCondition = { + attribute: "partnerEnrolledDays", + operator: "gte", + value: 1, +} as const; + +export async function publishTransactionalCampaign( + api: ApiClient, + overrides: Record = {}, +) { + const { status, data } = await createCampaign(api); + expect(status).toEqual(201); + + const patched = await api.patch(`/api/campaigns/${data.id}`, { + ...campaignContent({ + triggerConditions: [enrolledDaysCondition], + ...overrides, + }), + status: "active", + ...overrides, + }); + + expect(patched.status).toEqual(200); + return data.id; +} + +export async function getCampaignWorkflow(campaignId: string) { + return prisma.workflow.findFirstOrThrow({ + where: { + campaign: { + id: campaignId, + }, + }, + }); +} + +export async function runScheduledCampaignWorkflow(workflowId: string) { + const response = await fetch( + `${PLAYWRIGHT_API_BASE}/api/cron/workflows/${workflowId}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }, + ); + const message = await response.text(); + + expect(response.status).toEqual(200); + + if (message.includes("disabled")) { + return "disabled"; + } + + if (message.includes("not found")) { + return "not found"; + } + + return "finished"; +} + +export async function createTestPartner( + api: ApiClient, + overrides: Record = {}, +) { + const { status, data } = await api.post( + "/api/partners", + { + name: randomName("partner"), + email: randomPartnerEmail(), + ...overrides, + }, + ); + + expect(status).toEqual(201); + return data; +} + +export async function createPartnerMailbox(partnerId: string) { + const partner = await prisma.partner.findUniqueOrThrow({ + where: { id: partnerId }, + select: { email: true, name: true }, + }); + + const user = await prisma.user.create({ + data: { + id: createId({ prefix: "user_" }), + email: partner.email!, + name: partner.name, + emailVerified: new Date(), + defaultPartnerId: partnerId, + }, + }); + + await prisma.partnerUser.create({ + data: { + userId: user.id, + partnerId, + role: "owner", + notificationPreferences: { + create: {}, + }, + }, + }); + + return user.id; +} + +export async function backdateEnrollment({ + partnerId, + programId, + hoursAgo, +}: { + partnerId: string; + programId: string; + hoursAgo: number; +}) { + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId, + programId, + }, + }, + data: { + createdAt: subHours(new Date(), hoursAgo), + }, + }); +} + +export async function setEnrollmentStatus({ + partnerId, + programId, + status, +}: { + partnerId: string; + programId: string; + status: "pending" | "approved" | "banned"; +}) { + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId, + programId, + }, + }, + data: { status }, + }); +} + +export async function setLinkStats({ + partnerId, + programId, + leads, + conversions, + saleAmount, +}: { + partnerId: string; + programId: string; + leads?: number; + conversions?: number; + saleAmount?: number; +}) { + const link = await prisma.link.findFirst({ + where: { partnerId, programId }, + orderBy: { id: "asc" }, + select: { id: true }, + }); + + expect(link).not.toBeNull(); + + await prisma.link.update({ + where: { id: link!.id }, + data: { + ...(leads !== undefined && { leads }), + ...(conversions !== undefined && { conversions }), + ...(saleAmount !== undefined && { saleAmount }), + }, + }); +} + +export async function createTestCommission({ + programId, + partnerId, + earnings, +}: { + programId: string; + partnerId: string; + earnings: number; +}) { + return prisma.commission.create({ + data: { + id: createId({ prefix: "cm_" }), + programId, + partnerId, + type: "sale", + amount: earnings, + quantity: 1, + earnings, + status: "pending", + }, + }); +} + +export async function tagPartner({ + programId, + partnerId, + partnerTagId, +}: { + programId: string; + partnerId: string; + partnerTagId: string; +}) { + await prisma.programPartnerTag.create({ + data: { + programId, + partnerId, + partnerTagId, + }, + }); +} + +export async function insertCampaignEmail({ + campaignId, + programId, + partnerId, + recipientUserId, +}: { + campaignId: string; + programId: string; + partnerId: string; + recipientUserId: string; +}) { + return prisma.notificationEmail.create({ + data: { + id: createId({ prefix: "em_" }), + type: "Campaign", + emailId: `pw_${nanoid()}`, + campaignId, + programId, + partnerId, + recipientUserId, + }, + }); +} + +export async function campaignEmails(campaignId: string, partnerId?: string) { + return prisma.notificationEmail.findMany({ + where: { + campaignId, + type: "Campaign", + ...(partnerId && { partnerId }), + }, + }); +} + +export async function expectCampaignEmailCount({ + campaignId, + partnerId, + count, +}: { + campaignId: string; + partnerId?: string; + count: number; +}) { + const emails = await campaignEmails(campaignId, partnerId); + expect( + emails, + count > 0 + ? "expected a campaign NotificationEmail (SMTP/MailHog or Resend must be configured)" + : "did not expect a campaign NotificationEmail", + ).toHaveLength(count); + return emails; +} + +export async function createTestGroup(api: ApiClient) { + const slug = `g-${nanoid(8).toLowerCase()}`; + const { status, data } = await api.post<{ id: string }>("/api/groups", { + name: randomName("group"), + slug, + color: "blue", + }); + + expect(status).toEqual(201); + return data.id; +} + +export async function deleteTestGroup(api: ApiClient, id: string | undefined) { + if (!id) return; + await api.delete(`/api/groups/${id}`); +} + +export async function deleteTestPartner(partnerId: string | undefined) { + if (!partnerId) return; + + const partnerUsers = await prisma.partnerUser.findMany({ + where: { partnerId }, + select: { userId: true }, + }); + + await prisma.notificationEmail.deleteMany({ + where: { partnerId }, + }); + + await prisma.commission.deleteMany({ + where: { partnerId }, + }); + + await prisma.customer.deleteMany({ + where: { partnerId }, + }); + + await prisma.programPartnerTag.deleteMany({ + where: { partnerId }, + }); + + if (partnerUsers.length > 0) { + await prisma.user.deleteMany({ + where: { + id: { + in: partnerUsers.map((row) => row.userId), + }, + }, + }); + } + + await prisma.link.deleteMany({ + where: { partnerId }, + }); + + await prisma.programEnrollment.deleteMany({ + where: { partnerId }, + }); + + // Prisma partner.delete hits a PlanetScale relation quirk; raw SQL matches + // bulkDeletePartners cleanup used by e2e cron. Use Prisma so cleanup hits + // DATABASE_URL, not PLANETSCALE_DATABASE_URL. + await prisma.$executeRaw`DELETE FROM Partner WHERE id = ${partnerId}`; +} + +export async function cleanupCampaign(api: ApiClient, campaignId?: string) { + if (!campaignId) return; + await prisma.notificationEmail.deleteMany({ + where: { campaignId }, + }); + await deleteCampaign(api, campaignId); +} + +type CreatePartnerOptions = { + mailbox?: boolean; + hoursAgo?: number | null; + groupId?: string; +}; + +export function createCampaignSession( + api: ApiClient, + program: { id: string; defaultGroupId: string }, +) { + const partnerIds: string[] = []; + const campaignIds: string[] = []; + const groupIds: string[] = []; + const tagIds: string[] = []; + const programId = program.id; + + return { + programId, + defaultGroupId: program.defaultGroupId, + + trackPartner(partnerId: string) { + partnerIds.push(partnerId); + }, + + trackCampaign(campaignId: string) { + campaignIds.push(campaignId); + }, + + async createGroup() { + const groupId = await createTestGroup(api); + groupIds.push(groupId); + return groupId; + }, + + async createTag() { + const tag = await createPartnerTag(programId); + tagIds.push(tag.id); + return tag; + }, + + async setup(overrides: Record = {}) { + const campaignId = await publishTransactionalCampaign(api, overrides); + campaignIds.push(campaignId); + const workflow = await getCampaignWorkflow(campaignId); + + return { + id: campaignId, + workflow, + + async createPartner(options: CreatePartnerOptions = {}) { + const partner = await createTestPartner(api, { + ...(options.groupId && { groupId: options.groupId }), + }); + partnerIds.push(partner.id); + + if (options.mailbox !== false) { + await createPartnerMailbox(partner.id); + } + + if (options.hoursAgo !== null) { + await backdateEnrollment({ + partnerId: partner.id, + programId, + hoursAgo: options.hoursAgo ?? 18, + }); + } + + return partner; + }, + + async run() { + return runScheduledCampaignWorkflow(workflow.id); + }, + + async expectSentTo(partner: Pick) { + await expectCampaignEmailCount({ + campaignId, + partnerId: partner.id, + count: 1, + }); + }, + + async expectNotSentTo(partner: Pick) { + await expectCampaignEmailCount({ + campaignId, + partnerId: partner.id, + count: 0, + }); + }, + + async disableWorkflow() { + await prisma.workflow.update({ + where: { id: workflow.id }, + data: { disabledAt: new Date() }, + }); + }, + }; + }, + + async cleanup() { + for (const partnerId of partnerIds) { + await deleteTestPartner(partnerId); + } + for (const campaignId of campaignIds) { + await cleanupCampaign(api, campaignId); + } + for (const tagId of tagIds) { + await deletePartnerTag(tagId); + } + for (const groupId of groupIds) { + await deleteTestGroup(api, groupId); + } + }, + }; +} + +export type CampaignSession = ReturnType; +export type ScheduledCampaign = Awaited< + ReturnType +>; diff --git a/apps/web/playwright/api/campaigns/send-campaign-workflow.spec.ts b/apps/web/playwright/api/campaigns/send-campaign-workflow.spec.ts new file mode 100644 index 00000000000..052a14b24f0 --- /dev/null +++ b/apps/web/playwright/api/campaigns/send-campaign-workflow.spec.ts @@ -0,0 +1,384 @@ +import { prisma } from "@/lib/prisma"; +import { expect } from "@playwright/test"; +import { trackClick, trackLead } from "../conversions/helpers"; +import { createCampaign } from "./helpers"; +import { test } from "./send-campaign-workflow-fixtures"; +import { + campaignEmails, + createTestCommission, + enrolledDaysCondition, + getCampaignWorkflow, + insertCampaignEmail, + runScheduledCampaignWorkflow, + setEnrollmentStatus, + setLinkStats, + tagPartner, +} from "./send-campaign-workflow-helpers"; + +test.describe("Lifecycle", () => { + test("transactional draft workflow is disabled", async ({ + api, + campaign, + }) => { + const { status, data } = await createCampaign(api); + expect(status).toEqual(201); + campaign.trackCampaign(data.id); + + const workflow = await getCampaignWorkflow(data.id); + expect(workflow.disabledAt).not.toBeNull(); + expect(workflow.actions).toEqual([ + { + type: "sendCampaign", + data: { campaignId: data.id }, + }, + ]); + }); + + test("publishing a transactional campaign enables the workflow", async ({ + campaign, + }) => { + const ctx = await campaign.setup(); + + expect(ctx.workflow.disabledAt).toBeNull(); + expect(ctx.workflow.triggerConditions).toEqual([enrolledDaysCondition]); + expect(ctx.workflow.actions).toEqual([ + { + type: "sendCampaign", + data: { campaignId: ctx.id }, + }, + ]); + }); + + test("pausing a campaign disables the workflow", async ({ + api, + campaign, + }) => { + const ctx = await campaign.setup(); + + const paused = await api.patch(`/api/campaigns/${ctx.id}`, { + status: "paused", + }); + expect(paused.status).toEqual(200); + + const workflow = await getCampaignWorkflow(ctx.id); + expect(workflow.disabledAt).not.toBeNull(); + }); + + test("draft and paused campaigns do not send", async ({ api, campaign }) => { + const { data: draft } = await createCampaign(api); + campaign.trackCampaign(draft.id); + await api.patch(`/api/campaigns/${draft.id}`, { + triggerConditions: [enrolledDaysCondition], + }); + + const paused = await campaign.setup(); + await api.patch(`/api/campaigns/${paused.id}`, { status: "paused" }); + + const partner = await paused.createPartner(); + const draftWorkflow = await getCampaignWorkflow(draft.id); + + expect(await runScheduledCampaignWorkflow(draftWorkflow.id)).toEqual( + "disabled", + ); + expect(await paused.run()).toEqual("disabled"); + await paused.expectNotSentTo(partner); + expect(await campaignEmails(draft.id, partner.id)).toHaveLength(0); + }); +}); + +test.describe("Scheduled window and recipients", () => { + test("scheduled run skips a disabled workflow", async ({ campaign }) => { + const ctx = await campaign.setup(); + const partner = await ctx.createPartner(); + await ctx.disableWorkflow(); + + expect(await ctx.run()).toEqual("disabled"); + await ctx.expectNotSentTo(partner); + }); + + test("scheduled window only includes enrollments from 12–24h ago", async ({ + campaign, + }) => { + const ctx = await campaign.setup(); + const tooRecent = await ctx.createPartner({ hoursAgo: 6 }); + const inWindow = await ctx.createPartner({ hoursAgo: 18 }); + const tooOld = await ctx.createPartner({ hoursAgo: 30 }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectNotSentTo(tooRecent); + await ctx.expectSentTo(inWindow); + await ctx.expectNotSentTo(tooOld); + }); + + test("scheduled run skips eligible partners without a partner user", async ({ + campaign, + }) => { + const ctx = await campaign.setup(); + const partner = await ctx.createPartner({ mailbox: false }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectNotSentTo(partner); + }); + + test("scheduled run does not send duplicate campaign emails", async ({ + campaign, + }) => { + const ctx = await campaign.setup(); + const partner = await ctx.createPartner(); + + expect(await ctx.run()).toEqual("finished"); + expect(await ctx.run()).toEqual("finished"); + + const emails = await campaignEmails(ctx.id, partner.id); + expect(emails).toHaveLength(1); + expect(emails[0].type).toEqual("Campaign"); + }); + + test("scheduled run skips partners who already received the campaign", async ({ + campaign, + }) => { + const ctx = await campaign.setup(); + const partner = await ctx.createPartner(); + const mailbox = await prisma.partnerUser.findFirstOrThrow({ + where: { partnerId: partner.id }, + select: { userId: true }, + }); + + const existing = await insertCampaignEmail({ + campaignId: ctx.id, + programId: campaign.programId, + partnerId: partner.id, + recipientUserId: mailbox.userId, + }); + + expect(await ctx.run()).toEqual("finished"); + + const emails = await campaignEmails(ctx.id, partner.id); + expect(emails).toHaveLength(1); + expect(emails[0].id).toEqual(existing.id); + }); +}); + +test.describe("Scheduled AND conditions", () => { + const scheduledAndCases = [ + { + title: "sends when enrollment window and leads match", + condition: { attribute: "totalLeads", operator: "gte", value: 1 }, + sent: true, + seed: (partner, programId) => + setLinkStats({ + partnerId: partner.id, + programId, + leads: 1, + }), + }, + { + title: "does not send when the metric condition fails", + condition: { attribute: "totalLeads", operator: "gte", value: 1 }, + sent: false, + }, + { + title: "sends when totalLeads lte 0", + condition: { attribute: "totalLeads", operator: "lte", value: 0 }, + sent: true, + }, + { + title: "sends when totalConversions matches", + condition: { + attribute: "totalConversions", + operator: "gte", + value: 1, + }, + sent: true, + seed: (partner, programId) => + setLinkStats({ + partnerId: partner.id, + programId, + conversions: 1, + }), + }, + { + title: "sends when totalSaleAmount matches", + condition: { + attribute: "totalSaleAmount", + operator: "gte", + value: 100, + }, + sent: true, + seed: (partner, programId) => + setLinkStats({ + partnerId: partner.id, + programId, + saleAmount: 100, + }), + }, + { + title: "sends when totalCommissions matches", + condition: { + attribute: "totalCommissions", + operator: "gte", + value: 1, + }, + sent: true, + seed: async (partner, programId) => { + await createTestCommission({ + programId, + partnerId: partner.id, + earnings: 500, + }); + }, + }, + ]; + + for (const { title, condition, sent, seed } of scheduledAndCases) { + test(`scheduled AND ${title}`, async ({ campaign }) => { + const ctx = await campaign.setup({ + triggerConditions: [enrolledDaysCondition, condition], + }); + const partner = await ctx.createPartner(); + await seed?.(partner, campaign.programId); + + expect(await ctx.run()).toEqual("finished"); + if (sent) { + await ctx.expectSentTo(partner); + } else { + await ctx.expectNotSentTo(partner); + } + }); + } +}); + +test.describe("Audience", () => { + test("group filter only sends to partners in selected groups", async ({ + campaign, + }) => { + const groupId = await campaign.createGroup(); + const ctx = await campaign.setup({ groupIds: [groupId] }); + const inGroup = await ctx.createPartner({ groupId }); + const outGroup = await ctx.createPartner({ + groupId: campaign.defaultGroupId, + }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectSentTo(inGroup); + await ctx.expectNotSentTo(outGroup); + }); + + test("partner tag filter only sends to tagged partners", async ({ + campaign, + }) => { + const tag = await campaign.createTag(); + const ctx = await campaign.setup({ partnerTagIds: [tag.id] }); + const tagged = await ctx.createPartner(); + await tagPartner({ + programId: campaign.programId, + partnerId: tagged.id, + partnerTagId: tag.id, + }); + const untagged = await ctx.createPartner(); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectSentTo(tagged); + await ctx.expectNotSentTo(untagged); + }); + + test("group and tag filters both have to match", async ({ campaign }) => { + const groupId = await campaign.createGroup(); + const tag = await campaign.createTag(); + const ctx = await campaign.setup({ + groupIds: [groupId], + partnerTagIds: [tag.id], + }); + const partner = await ctx.createPartner({ groupId }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectNotSentTo(partner); + + await tagPartner({ + programId: campaign.programId, + partnerId: partner.id, + partnerTagId: tag.id, + }); + expect(await ctx.run()).toEqual("finished"); + await ctx.expectSentTo(partner); + }); + + test("non-approved enrollments are not sent", async ({ campaign }) => { + const ctx = await campaign.setup(); + const partner = await ctx.createPartner(); + await setEnrollmentStatus({ + partnerId: partner.id, + programId: campaign.programId, + status: "pending", + }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectNotSentTo(partner); + }); +}); + +test.describe("Event path", () => { + test("partnerJoined does not send on the scheduled runner", async ({ + campaign, + }) => { + const ctx = await campaign.setup({ + triggerConditions: [ + { attribute: "partnerJoined", operator: "gte", value: 0 }, + ], + }); + const partner = await ctx.createPartner({ hoursAgo: null }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectNotSentTo(partner); + }); + + test("leadRecorded sends when totalLeads matches and skips when it does not", async ({ + campaign, + }) => { + const ctx = await campaign.setup({ + triggerConditions: [ + { attribute: "totalLeads", operator: "gte", value: 2 }, + ], + }); + + const match = await ctx.createPartner({ hoursAgo: null }); + const matchClick1 = await trackClick({ + domain: match.links![0].domain, + key: match.links![0].key, + }); + await trackLead({ clickId: matchClick1.clickId }); + const matchClick2 = await trackClick({ + domain: match.links![0].domain, + key: match.links![0].key, + }); + await trackLead({ clickId: matchClick2.clickId }); + + const miss = await ctx.createPartner({ hoursAgo: null }); + const missClick = await trackClick({ + domain: miss.links![0].domain, + key: miss.links![0].key, + }); + await trackLead({ clickId: missClick.clickId }); + + await ctx.expectSentTo(match); + await ctx.expectNotSentTo(miss); + }); + + test("event runner skips disabled workflows", async ({ campaign }) => { + const ctx = await campaign.setup({ + triggerConditions: [ + { attribute: "totalLeads", operator: "gte", value: 1 }, + ], + }); + const partner = await ctx.createPartner({ hoursAgo: null }); + await ctx.disableWorkflow(); + + const { clickId } = await trackClick({ + domain: partner.links![0].domain, + key: partner.links![0].key, + }); + await trackLead({ clickId }); + + await ctx.expectNotSentTo(partner); + }); +}); diff --git a/apps/web/playwright/api/constants.ts b/apps/web/playwright/api/constants.ts new file mode 100644 index 00000000000..ff56f224175 --- /dev/null +++ b/apps/web/playwright/api/constants.ts @@ -0,0 +1 @@ +export const PLAYWRIGHT_API_BASE = "http://localhost:8888"; diff --git a/apps/web/playwright/api/conversions/helpers.ts b/apps/web/playwright/api/conversions/helpers.ts new file mode 100644 index 00000000000..3c68b1d9780 --- /dev/null +++ b/apps/web/playwright/api/conversions/helpers.ts @@ -0,0 +1,83 @@ +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { readFileSync } from "fs"; +import path from "path"; +import { randomCustomer } from "../../utils"; +import { PLAYWRIGHT_API_BASE } from "../constants"; + +const TRACK_CLICK_HEADERS = { + referer: "https://dub.co", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", +}; + +function playwrightApiToken() { + return JSON.parse( + readFileSync(path.join(__dirname, "../../.auth/api.json"), "utf-8"), + ).token as string; +} + +async function postAuthenticatedJson( + url: string, + body: unknown, + extraHeaders: Record = {}, +) { + const response = await fetch(`${PLAYWRIGHT_API_BASE}${url}`, { + method: "POST", + headers: { + Authorization: `Bearer ${playwrightApiToken()}`, + "Content-Type": "application/json", + ...extraHeaders, + }, + body: JSON.stringify(body), + }); + + return { + status: response.status, + data: (await response.json()) as Record, + }; +} + +export async function trackClick({ + domain, + key, +}: { + domain: string; + key: string; +}) { + const { status, data } = await postAuthenticatedJson( + "/api/track/click", + { domain, key }, + TRACK_CLICK_HEADERS, + ); + + expect(status).toEqual(200); + expect(data.clickId).toEqual(expect.any(String)); + + return data as { clickId: string }; +} + +export async function trackLead({ + clickId, + ...overrides +}: { + clickId: string; +} & Record) { + const customer = randomCustomer(); + + const { status, data } = await postAuthenticatedJson("/api/track/lead", { + clickId, + eventName: `Signup-${nanoid()}`, + customerExternalId: customer.externalId, + customerEmail: customer.email, + customerName: customer.name, + ...overrides, + }); + + expect(status).toEqual(200); + + return { + customer, + data, + }; +} diff --git a/apps/web/playwright/api/customers/customers-pagination.spec.ts b/apps/web/playwright/api/customers/customers-pagination.spec.ts index 9792aad5a82..5aae7a8d0b3 100644 --- a/apps/web/playwright/api/customers/customers-pagination.spec.ts +++ b/apps/web/playwright/api/customers/customers-pagination.spec.ts @@ -4,6 +4,7 @@ import type { Customer } from "@/lib/types"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; import { + apiError, expectNoOverlap, expectSortedByCreatedAt, expectSortedById, @@ -13,61 +14,50 @@ import { test } from "../fixtures"; const PAGE_SIZE = 5; const SEED_COUNT = 25; -test.describe.configure({ - mode: "parallel", -}); - test("GET /customers – rejects both startingAfter and endingBefore", async ({ api, }) => { - const { status, data: error } = await api.get( - `/api/customers?${new URLSearchParams({ - pageSize: String(PAGE_SIZE), - startingAfter: "id", - endingBefore: "id", - })}`, - ); - - expect(status).toEqual(422); - expect(error).toStrictEqual({ - error: { + expect( + await api.get( + `/api/customers?${new URLSearchParams({ + pageSize: String(PAGE_SIZE), + startingAfter: "id", + endingBefore: "id", + })}`, + ), + ).toEqual( + apiError({ code: "unprocessable_entity", message: "You cannot use both startingAfter and endingBefore at the same time.", - doc_url: "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }); + }), + ); }); test("GET /customers – rejects page > MAX_OFFSET_PAGE", async ({ api }) => { - const { status, data: error } = await api.get( - `/api/customers?${new URLSearchParams({ - page: "1001", - pageSize: "10", - })}`, - ); - - expect(status).toEqual(422); - expect(error).toStrictEqual({ - error: { + expect( + await api.get( + `/api/customers?${new URLSearchParams({ + page: "1001", + pageSize: "10", + })}`, + ), + ).toEqual( + apiError({ code: "unprocessable_entity", message: "Page is too big (cannot be more than 1000), recommend using cursor-based pagination instead.", - doc_url: "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }); + }), + ); }); test("GET /customers – invalid cursor ID (startingAfter / endingBefore)", async ({ api, }) => { - const invalidCursorError = { - error: { - code: "unprocessable_entity", - message: "Invalid cursor: the provided ID does not exist.", - doc_url: "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }; + const invalidCursorError = apiError({ + code: "unprocessable_entity", + message: "Invalid cursor: the provided ID does not exist.", + }); const { status: statusAfter, data: errorAfter } = await api.get( `/api/customers?${new URLSearchParams({ @@ -76,8 +66,7 @@ test("GET /customers – invalid cursor ID (startingAfter / endingBefore)", asyn })}`, ); - expect(statusAfter).toEqual(422); - expect(errorAfter).toStrictEqual(invalidCursorError); + expect({ status: statusAfter, data: errorAfter }).toEqual(invalidCursorError); const { status: statusBefore, data: errorBefore } = await api.get( `/api/customers?${new URLSearchParams({ @@ -86,8 +75,9 @@ test("GET /customers – invalid cursor ID (startingAfter / endingBefore)", asyn })}`, ); - expect(statusBefore).toEqual(422); - expect(errorBefore).toStrictEqual(invalidCursorError); + expect({ status: statusBefore, data: errorBefore }).toEqual( + invalidCursorError, + ); }); test.describe("with seeded customers", () => { @@ -224,15 +214,11 @@ test.describe("with seeded customers", () => { test("GET /customers – rejects mixing page with startingAfter / endingBefore", async ({ api, }) => { - const mixedPaginationError = { - error: { - code: "unprocessable_entity", - message: - "You cannot use both page and startingAfter/endingBefore at the same time. Please use one pagination method.", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }; + const mixedPaginationError = apiError({ + code: "unprocessable_entity", + message: + "You cannot use both page and startingAfter/endingBefore at the same time. Please use one pagination method.", + }); const { status: statusAfter, data: errorAfter } = await api.get( `/api/customers?${new URLSearchParams({ @@ -242,8 +228,9 @@ test.describe("with seeded customers", () => { })}`, ); - expect(statusAfter).toEqual(422); - expect(errorAfter).toStrictEqual(mixedPaginationError); + expect({ status: statusAfter, data: errorAfter }).toEqual( + mixedPaginationError, + ); const { status: statusBefore, data: errorBefore } = await api.get( `/api/customers?${new URLSearchParams({ @@ -253,8 +240,9 @@ test.describe("with seeded customers", () => { })}`, ); - expect(statusBefore).toEqual(422); - expect(errorBefore).toStrictEqual(mixedPaginationError); + expect({ status: statusBefore, data: errorBefore }).toEqual( + mixedPaginationError, + ); }); test("GET /customers – rejects cursor pagination with unsupported sort field", async ({ @@ -268,15 +256,12 @@ test.describe("with seeded customers", () => { })}`, ); - expect(status).toEqual(422); - expect(error).toStrictEqual({ - error: { + expect({ status, data: error }).toEqual( + apiError({ code: "unprocessable_entity", message: "Cursor-based pagination only supports sorting by `createdAt`. Use offset-based pagination (page/pageSize) for other sort fields.", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }); + }), + ); }); }); diff --git a/apps/web/playwright/api/customers/customers.spec.ts b/apps/web/playwright/api/customers/customers.spec.ts index ed492a90a8e..0fdbc971cd1 100644 --- a/apps/web/playwright/api/customers/customers.spec.ts +++ b/apps/web/playwright/api/customers/customers.spec.ts @@ -36,10 +36,6 @@ async function deleteCustomer(api: ApiClient, id: string | undefined) { await api.delete(`/api/customers/${id}`); } -test.describe.configure({ - mode: "parallel", -}); - test("POST /customers", async ({ api }) => { let customerId: string | undefined; const body = randomCustomer(); diff --git a/apps/web/playwright/api/domains/domains.spec.ts b/apps/web/playwright/api/domains/domains.spec.ts index 757b776d883..70ba8a29868 100644 --- a/apps/web/playwright/api/domains/domains.spec.ts +++ b/apps/web/playwright/api/domains/domains.spec.ts @@ -1,6 +1,6 @@ import type { DomainProps } from "@/lib/types"; import { expect } from "@playwright/test"; -import { randomName } from "../../utils"; +import { apiError, randomName } from "../../utils"; import { test, type ApiClient } from "../fixtures"; test.describe.configure({ @@ -283,51 +283,31 @@ test("DELETE /domains/{slug}", async ({ api }) => { }); test("GET /domains/status – not eligible", async ({ api }) => { - const { status, data } = await api.get( - "/api/domains/status?domains=example.link", - ); - - expect(status).toEqual(403); - expect(data).toEqual({ - error: { + expect(await api.get("/api/domains/status?domains=example.link")).toEqual( + apiError({ code: "forbidden", message: "GET /domains/status is not available for your workspace. Contact support for more information.", - doc_url: "https://dub.co/docs/api-reference/errors#forbidden", - }, - }); + }), + ); }); const errorCases = [ { name: "POST /domains – without slug", body: {}, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: "invalid_type: slug: slug is required", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: "invalid_type: slug: slug is required", + }), }, { name: "POST /domains – invalid domain", body: { slug: "not a domain" }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: "Invalid domain", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: "Invalid domain", + }), }, ]; @@ -348,14 +328,12 @@ test("POST /domains – existing slug", async ({ api }) => { slug, }); - expect(status).toEqual(409); - expect(data).toEqual({ - error: { + expect({ status, data }).toEqual( + apiError({ code: "conflict", message: "Domain is already in use.", - doc_url: "https://dub.co/docs/api-reference/errors#conflict", - }, - }); + }), + ); } finally { await deleteDomain(api, slug); } @@ -366,14 +344,12 @@ test("GET /domains/{slug} – not found", async ({ api }) => { const { status, data } = await api.get(`/api/domains/${slug}`); - expect(status).toEqual(404); - expect(data).toEqual({ - error: { + expect({ status, data }).toEqual( + apiError({ code: "not_found", message: `Domain ${slug} not found.`, - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }); + }), + ); }); test.describe("JSON config fields", () => { @@ -490,17 +466,12 @@ test.describe("JSON config fields", () => { ...domainBody(slug), [field]: INVALID_JSON, }), - ).toEqual({ - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: `Invalid ${label}`, - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }); + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: `Invalid ${label}`, + }), + ); }); test(`PATCH /domains/{slug} – invalid ${field} JSON`, async ({ api }) => { @@ -514,17 +485,12 @@ test.describe("JSON config fields", () => { await api.patch(`/api/domains/${slug}`, { [field]: INVALID_JSON, }), - ).toEqual({ - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: `Invalid ${label}`, - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }); + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: `Invalid ${label}`, + }), + ); } finally { await deleteDomain(api, slug); } diff --git a/apps/web/playwright/api/folders/folders.spec.ts b/apps/web/playwright/api/folders/folders.spec.ts index ca1dcede7fe..8a5101bcc0d 100644 --- a/apps/web/playwright/api/folders/folders.spec.ts +++ b/apps/web/playwright/api/folders/folders.spec.ts @@ -31,10 +31,6 @@ async function deleteFolder(api: ApiClient, folderId: string) { await api.delete(`/api/folders/${folderId}`); } -test.describe.configure({ - mode: "parallel", -}); - test("POST /folders", async ({ api }) => { let folderId: string | undefined; const folderName = randomName("folder"); diff --git a/apps/web/playwright/api/partners/ban-partner.spec.ts b/apps/web/playwright/api/partners/ban-partner.spec.ts index 2ee5ae7b5b9..14db60401e1 100644 --- a/apps/web/playwright/api/partners/ban-partner.spec.ts +++ b/apps/web/playwright/api/partners/ban-partner.spec.ts @@ -3,13 +3,9 @@ import { prisma } from "@/lib/prisma"; import type { EnrolledPartnerProps } from "@/lib/types"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; -import { randomName, randomPartnerEmail } from "../../utils"; +import { apiError, randomName, randomPartnerEmail } from "../../utils"; import { test, type ApiClient } from "../fixtures"; -test.describe.configure({ - mode: "parallel", -}); - async function createPartner( api: ApiClient, overrides: Record = {}, @@ -136,16 +132,12 @@ test("POST /partners/ban – already banned", async ({ api, program }) => { partnerId, reason: "spam", }), - ).toEqual({ - status: 400, - data: { - error: { - code: "bad_request", - message: "This partner is already banned from your program.", - doc_url: "https://dub.co/docs/api-reference/errors#bad-request", - }, - }, - }); + ).toEqual( + apiError({ + code: "bad_request", + message: "This partner is already banned from your program.", + }), + ); } finally { await deletePartner(partnerId); } @@ -159,16 +151,12 @@ test("POST /partners/ban – partner not found", async ({ api, program }) => { partnerId, reason: "fraud", }), - ).toEqual({ - status: 404, - data: { - error: { - code: "not_found", - message: `Partner ${partnerId} is not enrolled in program ${program.id}.`, - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }, - }); + ).toEqual( + apiError({ + code: "not_found", + message: `Partner ${partnerId} is not enrolled in program ${program.id}.`, + }), + ); }); test("POST /partners/ban – tenantId not found", async ({ api }) => { @@ -179,44 +167,28 @@ test("POST /partners/ban – tenantId not found", async ({ api }) => { tenantId, reason: "fraud", }), - ).toEqual({ - status: 404, - data: { - error: { - code: "not_found", - message: `Partner with tenantId ${tenantId} not found in program.`, - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }, - }); + ).toEqual( + apiError({ + code: "not_found", + message: `Partner with tenantId ${tenantId} not found in program.`, + }), + ); }); -const invalidReasonError = { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - 'invalid_value: reason: Invalid option: expected one of "tos_violation"|"inappropriate_content"|"fake_traffic"|"fraud"|"spam"|"brand_abuse"', - doc_url: "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, -}; +const invalidReasonError = apiError({ + code: "unprocessable_entity", + message: + 'invalid_value: reason: Invalid option: expected one of "tos_violation"|"inappropriate_content"|"fake_traffic"|"fraud"|"spam"|"brand_abuse"', +}); const banErrorCases = [ { name: "POST /partners/ban – missing partnerId and tenantId", body: { reason: "fraud" }, - expected: { - status: 400, - data: { - error: { - code: "bad_request", - message: "Either `partnerId` or `tenantId` must be provided.", - doc_url: "https://dub.co/docs/api-reference/errors#bad-request", - }, - }, - }, + expected: apiError({ + code: "bad_request", + message: "Either `partnerId` or `tenantId` must be provided.", + }), }, { name: "POST /partners/ban – missing reason", diff --git a/apps/web/playwright/api/partners/partners.spec.ts b/apps/web/playwright/api/partners/partners.spec.ts index 1fc27831ac6..d7fbc62a0bf 100644 --- a/apps/web/playwright/api/partners/partners.spec.ts +++ b/apps/web/playwright/api/partners/partners.spec.ts @@ -6,14 +6,10 @@ import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; import slugify from "@sindresorhus/slugify"; import * as z from "zod/v4"; -import { randomName, randomPartnerEmail } from "../../utils"; +import { apiError, randomName, randomPartnerEmail } from "../../utils"; import { test, type ApiClient } from "../fixtures"; import { TEST_WORKSPACE } from "../setup-test-workspace"; -test.describe.configure({ - mode: "parallel", -}); - const EnrolledPartnerSchema = EnrolledPartnerSchemaDate.extend({ createdAt: z.string(), bannedAt: z.string().nullish(), @@ -175,18 +171,13 @@ test("POST /partners – invalid username", async ({ api }) => { email: randomPartnerEmail(), username: "invalid username", }), - ).toEqual({ - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "custom: username: Invalid username. Must be a URL-friendly string.", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }); + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: + "custom: username: Invalid username. Must be a URL-friendly string.", + }), + ); }); test("POST /partners – linkProps.prefix on default link", async ({ diff --git a/apps/web/playwright/api/setup-test-workspace.ts b/apps/web/playwright/api/setup-test-workspace.ts index 36cead52259..ad01b773121 100644 --- a/apps/web/playwright/api/setup-test-workspace.ts +++ b/apps/web/playwright/api/setup-test-workspace.ts @@ -8,6 +8,7 @@ import { import { config as loadEnv } from "dotenv-flow"; import { mkdir, writeFile } from "fs/promises"; import path from "path"; +import { PLAYWRIGHT_API_BASE } from "./constants"; loadEnv({ silent: true, @@ -33,7 +34,6 @@ export const TEST_WORKSPACE = { } as const; const authFile = path.join(__dirname, "../.auth/api.json"); -const apiBaseURL = "http://localhost:8888"; // Upserts a dedicated Playwright API user, workspace, membership, // RestrictedToken, and partner program. Safe to run repeatedly from globalSetup. @@ -71,6 +71,7 @@ export async function setupTestWorkspace() { foldersLimit: 100, aiLimit: 1000, partnersLimit: 1000, + groupsLimit: 100, }, create: { id: createId({ prefix: "ws_" }), @@ -86,6 +87,7 @@ export async function setupTestWorkspace() { foldersLimit: 100, aiLimit: 1000, partnersLimit: 1000, + groupsLimit: 100, }, }); @@ -153,7 +155,7 @@ export async function setupTestWorkspace() { { token, workspaceId: workspace.id, - baseURL: apiBaseURL, + baseURL: PLAYWRIGHT_API_BASE, userId: user.id, workspaceSlug: workspace.slug, programId, diff --git a/apps/web/playwright/api/tags/tags.spec.ts b/apps/web/playwright/api/tags/tags.spec.ts index 7a51fe5adbb..787a7b6ecae 100644 --- a/apps/web/playwright/api/tags/tags.spec.ts +++ b/apps/web/playwright/api/tags/tags.spec.ts @@ -1,12 +1,8 @@ import { expect } from "@playwright/test"; import type { Tag } from "@prisma/client"; -import { randomName } from "../../utils"; +import { apiError, randomName } from "../../utils"; import { test } from "../fixtures"; -test.describe.configure({ - mode: "parallel", -}); - test("POST /tags", async ({ api }) => { let tagId: string | undefined; @@ -36,35 +32,21 @@ const errorCases = [ tag: "news", color: "invalid", }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "invalid_value: color: Invalid color. Must be one of: red, yellow, green, blue, purple, brown, gray, pink", // TODO: update this to use RESOURCE_COLORS - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: + "invalid_value: color: Invalid color. Must be one of: red, yellow, green, blue, purple, brown, gray, pink", // TODO: update this to use RESOURCE_COLORS + }), }, { name: "POST /tags – without name", body: { color: "red", }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: "custom: name: Name is required.", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: "custom: name: Name is required.", + }), }, ]; @@ -92,14 +74,12 @@ test("POST /tags – existing name", async ({ api }) => { color: "red", }); - expect(status).toBe(409); - expect(error).toEqual({ - error: { + expect({ status, data: error }).toEqual( + apiError({ code: "conflict", message: "A tag with that name already exists.", - doc_url: "https://dub.co/docs/api-reference/errors#conflict", - }, - }); + }), + ); } finally { if (tagId) await api.delete(`/api/tags/${tagId}`); } diff --git a/apps/web/playwright/api/utm/utm.spec.ts b/apps/web/playwright/api/utm/utm.spec.ts index cce166dbcf5..c747172b25d 100644 --- a/apps/web/playwright/api/utm/utm.spec.ts +++ b/apps/web/playwright/api/utm/utm.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test"; import type { UtmTemplate } from "@prisma/client"; -import { randomName } from "../../utils"; +import { apiError, randomName } from "../../utils"; import { test, type ApiClient } from "../fixtures"; type UtmTemplateResponse = Pick< @@ -49,10 +49,6 @@ async function deleteUtmTemplate(api: ApiClient, id: string | undefined) { await api.delete(`/api/utm/${id}`); } -test.describe.configure({ - mode: "parallel", -}); - test("POST /utm", async ({ api, workspace }) => { let id: string | undefined; const body = { @@ -122,65 +118,37 @@ const errorCases = [ { name: "POST /utm – missing name", body: {}, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "invalid_type: name: Invalid input: expected string, received undefined", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: + "invalid_type: name: Invalid input: expected string, received undefined", + }), }, { name: "POST /utm – empty name", body: { name: "" }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: "too_small: name: UTM name is required", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: "too_small: name: UTM name is required", + }), }, { name: "POST /utm – name too long", body: { name: "a".repeat(51) }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "too_big: name: Too big: expected string to have <=50 characters", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: + "too_big: name: Too big: expected string to have <=50 characters", + }), }, { name: "POST /utm – utm_source too long", body: { name: randomName("utm"), utm_source: "a".repeat(256) }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "too_big: utm_source: Too big: expected string to have <=255 characters", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: + "too_big: utm_source: Too big: expected string to have <=255 characters", + }), }, ]; @@ -204,14 +172,12 @@ test("POST /utm – existing name", async ({ api }) => { name: templateName, }); - expect(status).toBe(409); - expect(error).toEqual({ - error: { + expect({ status, data: error }).toEqual( + apiError({ code: "conflict", message: "A template with that name already exists.", - doc_url: "https://dub.co/docs/api-reference/errors#conflict", - }, - }); + }), + ); } finally { await deleteUtmTemplate(api, id); } @@ -294,16 +260,12 @@ test("PATCH /utm/{id}", async ({ api }) => { test("PATCH /utm/{id} – not found", async ({ api }) => { expect( await api.patch("/api/utm/utm_missing", { name: randomName("utm") }), - ).toEqual({ - status: 404, - data: { - error: { - code: "not_found", - message: "Template not found.", - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }, - }); + ).toEqual( + apiError({ + code: "not_found", + message: "Template not found.", + }), + ); }); test("DELETE /utm/{id}", async ({ api }) => { @@ -324,14 +286,10 @@ test("DELETE /utm/{id}", async ({ api }) => { }); test("DELETE /utm/{id} – not found", async ({ api }) => { - expect(await api.delete("/api/utm/utm_missing")).toEqual({ - status: 404, - data: { - error: { - code: "not_found", - message: "UTM template not found.", - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }, - }); + expect(await api.delete("/api/utm/utm_missing")).toEqual( + apiError({ + code: "not_found", + message: "UTM template not found.", + }), + ); }); diff --git a/apps/web/playwright/api/workspaces/workspaces.spec.ts b/apps/web/playwright/api/workspaces/workspaces.spec.ts index 524c4d98abc..a39cdd33550 100644 --- a/apps/web/playwright/api/workspaces/workspaces.spec.ts +++ b/apps/web/playwright/api/workspaces/workspaces.spec.ts @@ -2,13 +2,10 @@ import { WorkspaceSchema } from "@/lib/zod/schemas/workspaces"; import { expect } from "@playwright/test"; import type { Project } from "@prisma/client"; import * as z from "zod/v4"; +import { apiError } from "../../utils"; import { test } from "../fixtures"; import { TEST_WORKSPACE } from "../setup-test-workspace"; -test.describe.configure({ - mode: "parallel", -}); - test("GET /workspaces/{idOrSlug} – by id", async ({ api, workspace }) => { const { status, data: workspaceFetched } = await api.get( `/api/workspaces/${workspace.id}`, @@ -48,14 +45,10 @@ test("GET /workspaces/{idOrSlug} – by slug", async ({ api, workspace }) => { }); test("GET /workspaces/{idOrSlug} – invalid slug or id", async ({ api }) => { - const { status, data: error } = await api.get(`/api/workspaces/xxxx`); - - expect(status).toEqual(404); - expect(error).toStrictEqual({ - error: { + expect(await api.get(`/api/workspaces/xxxx`)).toEqual( + apiError({ code: "not_found", message: "Workspace not found.", - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }); + }), + ); }); diff --git a/apps/web/playwright/utils.ts b/apps/web/playwright/utils.ts index d7cdcae455e..ae7c445c354 100644 --- a/apps/web/playwright/utils.ts +++ b/apps/web/playwright/utils.ts @@ -1,7 +1,27 @@ +import { ErrorCodes } from "@/lib/api/error-codes"; import { generateRandomName } from "@/lib/names"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; +export function apiError({ + code, + message, +}: { + code: keyof typeof ErrorCodes; + message: string; +}) { + return { + status: ErrorCodes[code], + data: { + error: { + code, + message, + doc_url: `https://dub.co/docs/api-reference/errors#${code.replace("_", "-")}`, + }, + }, + }; +} + export function randomName(prefix = "e2e", length = 5) { return `${prefix}-${nanoid(length)}`; } diff --git a/apps/web/tests/campaigns/index.test.ts b/apps/web/tests/campaigns/index.test.ts deleted file mode 100644 index 4fed75034f2..00000000000 --- a/apps/web/tests/campaigns/index.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { Campaign, CampaignList } from "@/lib/types"; -import { updateCampaignSchema } from "@/lib/zod/schemas/campaigns"; -import { E2E_PARTNER_GROUP } from "tests/utils/resource"; -import { afterAll, describe, expect, test } from "vitest"; -import * as z from "zod/v4"; -import { IntegrationHarness } from "../utils/integration"; - -const campaign: z.infer = { - name: "Updated Test Campaign", - subject: "Updated Test Subject", - triggerConditions: [ - { - attribute: "totalConversions", - operator: "gte", - value: 50, - }, - ], - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Test campaign body", - }, - ], - }, - ], - }, -}; - -const expectedCampaign: Partial = { - ...campaign, - type: "transactional", - status: expect.any(String), - preview: null, - from: null, - scheduledAt: null, - groups: [{ id: E2E_PARTNER_GROUP.id }], - partnerTags: [], - createdAt: expect.any(String), - updatedAt: expect.any(String), -}; - -describe.sequential("/campaigns/**", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - let campaignId = ""; - const createdCampaignIds: string[] = []; - - afterAll(async () => { - await Promise.all(createdCampaignIds.map((id) => h.deleteCampaign(id))); - }); - - test("POST /campaigns - create draft campaign", async () => { - const { status, data } = await http.post<{ id: string }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - if (data?.id) { - campaignId = data.id; - createdCampaignIds.push(data.id); - } - - expect(status).toEqual(201); - expect(data).toMatchObject({ - id: expect.any(String), - }); - }); - - test("PATCH /campaigns/[campaignId] - update campaign content", async () => { - const { status, data: updatedCampaign } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - ...campaign, - groupIds: [E2E_PARTNER_GROUP.id], - }, - }); - - expect(status).toEqual(200); - expect(updatedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "draft", - }); - }); - - test("GET /campaigns/[campaignId] - make sure the draft campaign is created", async () => { - const { status, data: fetchedCampaign } = await http.get({ - path: `/campaigns/${campaignId}`, - }); - - expect(status).toEqual(200); - expect(fetchedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "draft", - }); - }); - - test("PATCH /campaigns/[campaignId] - invalid partner tag IDs", async () => { - const { status, data } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - partnerTagIds: ["invalid-partner-tag-id"], - }, - }); - - expect(status).toEqual(400); - expect(data).toMatchObject({ - error: { - message: "Invalid partner tag IDs detected: invalid-partner-tag-id", - code: "bad_request", - }, - }); - }); - - test("PATCH /campaigns/[campaignId] - clear partner tags", async () => { - const { status, data: updatedCampaign } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - partnerTagIds: null, - }, - }); - - expect(status).toEqual(200); - expect(updatedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "draft", - partnerTags: [], - }); - }); - - test("PATCH /campaigns/[campaignId] - publish campaign", async () => { - const { status, data: publishedCampaign } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - status: "active", - }, - }); - - expect(status).toEqual(200); - expect(publishedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "active", - }); - }); - - test("PATCH /campaigns/[campaignId] - pause campaign", async () => { - const { status, data: pausedCampaign } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - status: "paused", - }, - }); - - expect(status).toEqual(200); - expect(pausedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "paused", - }); - }); - - test("PATCH /campaigns/[campaignId] - resume campaign", async () => { - const { status, data: resumedCampaign } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - status: "active", - }, - }); - - expect(status).toEqual(200); - expect(resumedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "active", - }); - }); - - test("POST /campaigns/[campaignId]/duplicate - duplicate campaign", async () => { - const { status, data } = await http.post<{ id: string }>({ - path: `/campaigns/${campaignId}/duplicate`, - }); - - if (data?.id) { - createdCampaignIds.push(data.id); - } - - expect(status).toEqual(200); - expect(data.id).toBeDefined(); - - const { data: duplicatedCampaign } = await http.get({ - path: `/campaigns/${data.id}`, - }); - - expect(duplicatedCampaign).toStrictEqual({ - ...expectedCampaign, - id: data.id, - name: `${expectedCampaign.name} (copy)`, - status: "draft", - }); - }); - - test("GET /campaigns - list campaigns", async () => { - const { status, data: campaigns } = await http.get({ - path: "/campaigns", - }); - - expect(status).toEqual(200); - expect(Array.isArray(campaigns)).toBe(true); - expect(campaigns.length).toBeGreaterThan(0); - - const campaign = campaigns.find((c) => c.id === campaignId); - - expect(campaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - }); - }); - - test("GET /campaigns/[campaignId] - get single campaign", async () => { - const { status, data: fetchedCampaign } = await http.get({ - path: `/campaigns/${campaignId}`, - }); - - expect(status).toEqual(200); - expect(fetchedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "active", - }); - }); - - test("DELETE /campaigns/[campaignId] - delete campaign", async () => { - const { status, data } = await http.delete<{ id: string }>({ - path: `/campaigns/${campaignId}`, - }); - - expect(status).toEqual(200); - expect(data).toStrictEqual({ - id: campaignId, - }); - }); -}); diff --git a/apps/web/tests/commissions/calculate-sale-earnings.test.ts b/apps/web/tests/commissions/calculate-sale-earnings.test.ts new file mode 100644 index 00000000000..0c9c149afaa --- /dev/null +++ b/apps/web/tests/commissions/calculate-sale-earnings.test.ts @@ -0,0 +1,162 @@ +import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings"; +import { describe, expect, test } from "vitest"; + +describe("calculateSaleEarnings", () => { + describe("percentage – same as the old truncate path", () => { + test.each([ + { + label: "whole cents: $10 sale at 20%", + saleAmount: 1000, + percent: 20, + expected: 200, + }, + { + label: "whole cents: $10 sale at 50%", + saleAmount: 1000, + percent: 50, + expected: 500, + }, + { + label: "whole cents: $19 sale at 10%", + saleAmount: 1900, + percent: 10, + expected: 190, + }, + { + label: "1.4¢ truncates and rounds to the same value", + saleAmount: 7, + percent: 20, + expected: 1, + }, + { + label: "0.4¢ stays 0 (below half a cent)", + saleAmount: 2, + percent: 20, + expected: 0, + }, + { + label: "zero sale amount", + saleAmount: 0, + percent: 20, + expected: 0, + }, + { + label: "zero percent", + saleAmount: 1000, + percent: 0, + expected: 0, + }, + ])("$label → $expected", ({ saleAmount, percent, expected }) => { + expect( + calculateSaleEarnings({ + reward: { + type: "percentage", + amountInCents: null, + amountInPercentage: percent, + }, + sale: { amount: saleAmount, quantity: 1 }, + }), + ).toBe(expected); + }); + }); + + describe("percentage – half-up (new vs old truncate)", () => { + test.each([ + { + label: "3¢ sale at 20% (ticket: 0.6¢, used to store 0)", + saleAmount: 3, + percent: 20, + expected: 1, + }, + { + label: "15¢ sale at 10% (1.5¢, used to store 1)", + saleAmount: 15, + percent: 10, + expected: 2, + }, + { + label: "1¢ sale at 50% (0.5¢, used to store 0)", + saleAmount: 1, + percent: 50, + expected: 1, + }, + { + label: "500¢ at 2.9% (14.5¢; float 2.9/100 used to round to 14)", + saleAmount: 500, + percent: 2.9, + expected: 15, + }, + ])("$label → $expected", ({ saleAmount, percent, expected }) => { + expect( + calculateSaleEarnings({ + reward: { + type: "percentage", + amountInCents: null, + amountInPercentage: percent, + }, + sale: { amount: saleAmount, quantity: 1 }, + }), + ).toBe(expected); + }); + }); + + test("percentage ignores quantity (uses sale amount only)", () => { + expect( + calculateSaleEarnings({ + reward: { + type: "percentage", + amountInCents: null, + amountInPercentage: 20, + }, + sale: { amount: 1000, quantity: 5 }, + }), + ).toBe(200); + }); + + describe("flat", () => { + test.each([ + { + label: "single sale", + amountInCents: 5000, + quantity: 1, + expected: 5000, + }, + { + label: "quantity multiplies the flat amount", + amountInCents: 500, + quantity: 2, + expected: 1000, + }, + { + label: "zero quantity", + amountInCents: 500, + quantity: 0, + expected: 0, + }, + ])("$label → $expected", ({ amountInCents, quantity, expected }) => { + expect( + calculateSaleEarnings({ + reward: { + type: "flat", + amountInCents, + amountInPercentage: null, + }, + sale: { amount: 1000, quantity }, + }), + ).toBe(expected); + }); + }); + + test("returns 0 when reward type is neither flat nor percentage", () => { + expect( + calculateSaleEarnings({ + reward: { + type: "unknown" as "flat", + amountInCents: 500, + amountInPercentage: 20, + }, + sale: { amount: 1000, quantity: 1 }, + }), + ).toBe(0); + }); +}); diff --git a/apps/web/tests/workflows/send-campaign-workflow.test.ts b/apps/web/tests/workflows/send-campaign-workflow.test.ts deleted file mode 100644 index e8dcd031321..00000000000 --- a/apps/web/tests/workflows/send-campaign-workflow.test.ts +++ /dev/null @@ -1,876 +0,0 @@ -import { EnrolledPartnerProps } from "@/lib/types"; -import { Campaign } from "@prisma/client"; -import { subHours } from "date-fns"; -import { describe, expect, onTestFinished, test } from "vitest"; -import { randomPartnerEmail } from "../utils/helpers"; -import { IntegrationHarness } from "../utils/integration"; -import { E2E_USER_ID } from "../utils/resource"; -import { verifyCampaignSent } from "./utils/verify-campaign-sent"; - -describe.sequential("Workflow - SendCampaign", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - test("Workflow is created when transactional campaign is published", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - expect(campaign.id).toBeDefined(); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - const { status: updateStatus } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Test Campaign", - subject: "Welcome to our program!", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Thank you for joining!", - }, - ], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - }, - }); - - expect(updateStatus).toEqual(200); - - const { status: publishStatus, data: publishedCampaign } = - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - status: "active", - }, - }); - - expect(publishStatus).toEqual(200); - expect(publishedCampaign.status).toBe("active"); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - expect(workflow.disabledAt).toBeNull(); - - const workflowActions = workflow.actions as any[]; - expect(workflowActions[0].type).toBe("sendCampaign"); - expect(workflowActions[0].data.campaignId).toBe(campaignId); - }); - - test("Workflow doesn't execute when campaign is in draft", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - const { status: updateStatus, data: updatedCampaign } = - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Draft Campaign", - subject: "This should not be sent", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Draft content", - }, - ], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - }, - }); - - expect(updateStatus).toEqual(200); - expect(updatedCampaign.status).toBe("draft"); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - expect(workflow.disabledAt).not.toBeNull(); - }); - - test("Cron executes send campaign workflow", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Cron Campaign", - subject: "Welcome!", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Test content" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - const { status, data } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(data.message).toContain("Finished executing workflow"); - }); - - test("Cron skips disabled send campaign workflow", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Disabled Cron Campaign", - subject: "Should not be sent", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Test" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - // Disable workflow via E2E endpoint - await http.patch({ - path: `/e2e/workflows/${workflow.id}`, - body: { disabledAt: new Date().toISOString() }, - }); - - const { status, data } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(data.message).toContain("disabled"); - - const { data: emailsSent } = await http.get({ - path: "/e2e/notification-emails", - query: { campaignId }, - }); - - expect(emailsSent).toHaveLength(0); - }); - - test("Cron processes eligible partner enrollment", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Send Campaign", - subject: "Welcome partner!", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Hello!" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - Campaign Send", - email: randomPartnerEmail(), - }, - }); - - expect(partnerStatus).toEqual(201); - - // Backdate the enrollment to 18h ago so it falls in the cron window - await http.patch({ - path: "/e2e/enrollments", - body: { - partnerId: partner.id, - createdAt: subHours(new Date(), 18).toISOString(), - }, - }); - - const { status, data } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(data.message).toContain("Finished executing workflow"); - }); - - test("Cron doesn't send campaign when partner doesn't meet conditions", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E No Match Campaign", - subject: "Should not be sent", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Test" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - // Create a partner enrolled just now — doesn't match the 12-24h window - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - No Match", - email: randomPartnerEmail(), - }, - }); - - expect(partnerStatus).toEqual(201); - - const { status, data: triggerData } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(triggerData.message).toContain("Finished executing workflow"); - - const { data: emailsSent } = await http.get({ - path: "/e2e/notification-emails", - query: { campaignId, partnerId: partner.id }, - }); - - expect(emailsSent).toHaveLength(0); - }); - - test("No duplicate campaign sends on multiple cron executions", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await http.delete({ - path: "/e2e/notification-emails", - query: { campaignId }, - }); - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E No Dup Campaign", - subject: "No duplicates!", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Hello!" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - No Dup Campaign", - email: randomPartnerEmail(), - }, - }); - - expect(partnerStatus).toEqual(201); - - // Backdate enrollment to match the cron window - await http.patch({ - path: "/e2e/enrollments", - body: { - partnerId: partner.id, - createdAt: subHours(new Date(), 18).toISOString(), - }, - }); - - // Pre-insert a notification email to simulate a previous send - const { data: existingEmail } = await http.post({ - path: "/e2e/notification-emails", - body: { - campaignId, - partnerId: partner.id, - recipientUserId: E2E_USER_ID, - }, - }); - - expect(existingEmail).not.toBeNull(); - - // Trigger the workflow — should skip this partner (already sent) - const { status, data: triggerData } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(triggerData.message).toContain("Finished executing workflow"); - - // Verify still only 1 notification email (no duplicate) - const { data: emails } = await http.get({ - path: "/e2e/notification-emails", - query: { campaignId, partnerId: partner.id }, - }); - - expect(emails).toHaveLength(1); - expect(emails[0].id).toBe(existingEmail.id); - }); - - test("Campaign workflow configuration can be updated", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Campaign Config Test", - subject: "Test", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Test" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - const conditions1 = workflow.triggerConditions as any[]; - expect(conditions1[0].value).toBe(1); - - const { status: pauseStatus, data: pausedCampaign } = - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - status: "paused", - }, - }); - - expect(pauseStatus).toEqual(200); - expect(pausedCampaign.status).toBe("paused"); - - const { data: pausedWorkflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(pausedWorkflow.disabledAt).not.toBeNull(); - }); - - test("Campaign supports mixed enrollment and metric conditions", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - const { status: updateStatus, data: updatedCampaign } = - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Mixed Conditions Campaign", - subject: "Still no leads after 30 days", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Let's get those leads!" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 30, - }, - { - attribute: "totalLeads", - operator: "lte", - value: 0, - }, - ], - status: "active", - }, - }); - - expect(updateStatus).toEqual(200); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - expect(workflow.disabledAt).toBeNull(); - - const conditions = workflow.triggerConditions as any[]; - expect(conditions).toHaveLength(2); - expect(conditions[0]).toMatchObject({ - attribute: "partnerEnrolledDays", - operator: "gte", - value: 30, - }); - expect(conditions[1]).toMatchObject({ - attribute: "totalLeads", - operator: "lte", - value: 0, - }); - - // Response shape exposes the full conditions array - expect((updatedCampaign as any).triggerConditions).toEqual(conditions); - }); - - test( - "Cron sends campaign when all conditions are met (AND)", - { timeout: 90000 }, - async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await http.delete({ - path: "/e2e/notification-emails", - query: { campaignId }, - }); - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E AND Match Campaign", - subject: "You got a lead!", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Congrats on your first lead!" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - { - attribute: "totalLeads", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - AND Match", - email: randomPartnerEmail(), - }, - }); - - expect(partnerStatus).toEqual(201); - expect(partner.links).not.toBeNull(); - - // API-created partners have no PartnerUser, and campaign emails only - // go to users with an email. Create one and set link.leads directly so - // the cron AND conditions are met without racing Tinybird waitUntil. - const { status: enrollmentStatus } = await http.patch({ - path: "/e2e/enrollments", - body: { - partnerId: partner.id, - createdAt: subHours(new Date(), 18).toISOString(), - leads: 1, - createUser: true, - }, - }); - - expect(enrollmentStatus).toEqual(200); - - const { status, data } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(data.message).toContain("Finished executing workflow"); - - await verifyCampaignSent({ - http, - campaignId, - partnerId: partner.id, - }); - }, - ); - - test("Cron doesn't send when metric condition fails (AND)", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E AND Partial Match Campaign", - subject: "Should not be sent", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Test" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - { - attribute: "totalLeads", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - AND Partial", - email: randomPartnerEmail(), - }, - }); - - expect(partnerStatus).toEqual(201); - - // Enrollment window matches, but totalLeads is 0 — AND fails - await http.patch({ - path: "/e2e/enrollments", - body: { - partnerId: partner.id, - createdAt: subHours(new Date(), 18).toISOString(), - }, - }); - - const { status, data: triggerData } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(triggerData.message).toContain("Finished executing workflow"); - - const { data: emailsSent } = await http.get({ - path: "/e2e/notification-emails", - query: { campaignId, partnerId: partner.id }, - }); - - expect(emailsSent).toHaveLength(0); - }); -}); diff --git a/apps/web/tests/workflows/utils/verify-campaign-sent.ts b/apps/web/tests/workflows/utils/verify-campaign-sent.ts deleted file mode 100644 index aa8a433748e..00000000000 --- a/apps/web/tests/workflows/utils/verify-campaign-sent.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - VITEST_POLL_INTERVAL_MS, - VITEST_TEST_TIMEOUT_MS, -} from "@/lib/constants/misc"; -import { expect } from "vitest"; -import { HttpClient } from "../../utils/http"; - -interface VerifyCampaignSentProps { - http: HttpClient; - campaignId: string; - partnerId: string; -} - -export const verifyCampaignSent = async ({ - http, - campaignId, - partnerId, -}: VerifyCampaignSentProps) => { - const startTime = Date.now(); - - while (Date.now() - startTime < VITEST_TEST_TIMEOUT_MS) { - const { data: emails } = await http.get({ - path: "/e2e/notification-emails", - query: { campaignId, partnerId }, - }); - - const emailSent = emails?.[0]; - - if (emailSent) { - expect(emailSent.type).toBe("Campaign"); - expect(emailSent.campaignId).toBe(campaignId); - expect(emailSent.partnerId).toBe(partnerId); - return emailSent; - } - - await new Promise((resolve) => - setTimeout(resolve, VITEST_POLL_INTERVAL_MS), - ); - } - - throw new Error( - `Campaign email not found within ${VITEST_TEST_TIMEOUT_MS / 1000} seconds. ` + - `campaignId: ${campaignId}, partnerId: ${partnerId}`, - ); -}; diff --git a/apps/web/ui/modals/partner-link-modal.tsx b/apps/web/ui/modals/partner-link-modal.tsx index ce228bc3efd..53a409be6ab 100644 --- a/apps/web/ui/modals/partner-link-modal.tsx +++ b/apps/web/ui/modals/partner-link-modal.tsx @@ -183,13 +183,21 @@ function PartnerLinkModalContent({ }; }, [programEnrollment]); - const destinationDomains = useMemo( - () => - additionalLinks - .map((link) => link.domain) - .filter((d): d is string => d != null), - [additionalLinks], - ); + const destinationDomains = useMemo(() => { + const domains = additionalLinks + .map((link) => link.domain) + .filter((d): d is string => d != null); + + if (domains.length > 0) { + return domains; + } + + const programUrlDomain = programEnrollment?.program?.url + ? getDomainWithoutWWW(programEnrollment.program.url) + : null; + + return programUrlDomain ? [programUrlDomain] : []; + }, [additionalLinks, programEnrollment?.program?.url]); const [destinationDomain, setDestinationDomain] = useState( link diff --git a/apps/web/ui/partners/fraud-risks/partner-application-risk-summary-modal.tsx b/apps/web/ui/partners/fraud-risks/partner-application-risk-summary-modal.tsx index bff173dbead..83f90ae5053 100644 --- a/apps/web/ui/partners/fraud-risks/partner-application-risk-summary-modal.tsx +++ b/apps/web/ui/partners/fraud-risks/partner-application-risk-summary-modal.tsx @@ -13,6 +13,7 @@ import { useState, } from "react"; import { PartnerApplicationFraudSeverityIndicator } from "./partner-application-fraud-severity-indicator"; +import { RiskDisclaimerBanner } from "./risk-disclaimer-banner"; interface PartnerApplicationRiskSummaryModalProps { showModal: boolean; @@ -47,6 +48,10 @@ function PartnerApplicationRiskSummaryModal({
+ {severity === "high" && ( + + )} +
    diff --git a/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx b/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx index af38641da38..e850a7230a3 100644 --- a/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx +++ b/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx @@ -11,7 +11,6 @@ import Link from "next/link"; import { useAdvancedUpsellModal } from "../advanced-upsell-modal"; import { PartnerApplicationFraudSeverityIndicator } from "./partner-application-fraud-severity-indicator"; import { usePartnerApplicationRiskSummaryModal } from "./partner-application-risk-summary-modal"; -import { RiskDisclaimerBanner } from "./risk-disclaimer-banner"; interface PartnerApplicationRiskSummaryProps { partner: { @@ -85,10 +84,6 @@ export function PartnerApplicationRiskSummary({ ); })}
- - {severity === "high" && ( - - )}
diff --git a/apps/web/ui/partners/fraud-risks/partner-cross-program-summary.tsx b/apps/web/ui/partners/fraud-risks/partner-network-activity-summary.tsx similarity index 66% rename from apps/web/ui/partners/fraud-risks/partner-cross-program-summary.tsx rename to apps/web/ui/partners/fraud-risks/partner-network-activity-summary.tsx index 8fe10c09127..87692ef237b 100644 --- a/apps/web/ui/partners/fraud-risks/partner-cross-program-summary.tsx +++ b/apps/web/ui/partners/fraud-risks/partner-network-activity-summary.tsx @@ -1,25 +1,41 @@ "use client"; -import { usePartnerCrossProgramSummary } from "@/lib/swr/use-partner-cross-program-summary"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { partnerNetworkActivitySummarySchema } from "@/lib/zod/schemas/partners"; import { ActivityRing, User, UserCheck, UserXmark } from "@dub/ui"; +import { fetcher } from "@dub/utils"; +import useSWR from "swr"; +import * as z from "zod/v4"; -export function PartnerCrossProgramSummary({ +type NetworkActivitySummary = z.infer< + typeof partnerNetworkActivitySummarySchema +>; + +export function PartnerNetworkActivitySummary({ partnerId, }: { partnerId: string; }) { - const { crossProgramSummary, isLoading } = usePartnerCrossProgramSummary({ - partnerId, - }); + const { id: workspaceId } = useWorkspace(); + + const { data, isLoading } = useSWR( + workspaceId + ? `/api/partners/${partnerId}/network-activity?workspaceId=${workspaceId}` + : null, + fetcher, + { + revalidateOnMount: true, + }, + ); - if (isLoading || !crossProgramSummary) { + if (!data || isLoading) { return ; } - const { totalPrograms, activePrograms, bannedPrograms } = crossProgramSummary; + const { totalPrograms, activePrograms, bannedPrograms } = data; return ( -
+
{label}
- {value} - of {total} + + {value} + + + of {total} +
); @@ -65,7 +85,7 @@ function StatRow({ function LoadingSkeleton() { return ( -
+
diff --git a/apps/web/ui/partners/fraud-risks/partner-program-owner-activity.tsx b/apps/web/ui/partners/fraud-risks/partner-program-owner-activity.tsx deleted file mode 100644 index cbc4258ca33..00000000000 --- a/apps/web/ui/partners/fraud-risks/partner-program-owner-activity.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { getPlanCapabilities } from "@/lib/plan-capabilities"; -import useWorkspace from "@/lib/swr/use-workspace"; -import { PartnerCrossProgramSummary } from "./partner-cross-program-summary"; - -export function PartnerProgramOwnerActivity({ - partnerId, -}: { - partnerId: string; -}) { - const { plan } = useWorkspace(); - const { canManageFraudEvents } = getPlanCapabilities(plan); - - if (!canManageFraudEvents) { - return null; - } - - return ( -
-

- Program owner activity -

- -
- ); -} diff --git a/apps/web/ui/partners/fraud-risks/risk-review-sheet.tsx b/apps/web/ui/partners/fraud-risks/risk-review-sheet.tsx index 0a615d351a8..2eeb815fc4f 100644 --- a/apps/web/ui/partners/fraud-risks/risk-review-sheet.tsx +++ b/apps/web/ui/partners/fraud-risks/risk-review-sheet.tsx @@ -29,7 +29,7 @@ import useSWR from "swr"; import { AssociatedCommissionsTable } from "./associated-commissions-table"; import { FraudEventsTableWrapper } from "./fraud-events-tables"; import { useMarkAllAsFraudModal } from "./mark-all-as-fraud-modal"; -import { PartnerCrossProgramSummary } from "./partner-cross-program-summary"; +import { PartnerNetworkActivitySummary } from "./partner-network-activity-summary"; import { RequestDetailsBanner } from "./request-details-banner"; import { useResolveFraudGroupModal } from "./resolve-fraud-group-modal"; import { ResolvedRiskEventsTable } from "./resolved-risk-events-table"; @@ -226,12 +226,12 @@ function RiskReviewSheetContent({
-
+

- Program owner activity + Network activity

- +
diff --git a/apps/web/ui/partners/partner-info-cards.tsx b/apps/web/ui/partners/partner-info-cards.tsx index 7682f7f5c4f..a7ac9cab843 100644 --- a/apps/web/ui/partners/partner-info-cards.tsx +++ b/apps/web/ui/partners/partner-info-cards.tsx @@ -40,7 +40,7 @@ import Link from "next/link"; import { Fragment, ReactNode, createElement } from "react"; import useSWR from "swr"; import { PartnerApplicationRiskSummary } from "./fraud-risks/partner-application-risk-summary"; -import { PartnerProgramOwnerActivity } from "./fraud-risks/partner-program-owner-activity"; +import { PartnerNetworkActivitySummary } from "./fraud-risks/partner-network-activity-summary"; import { PartnerApplicationRiskBanner, PartnerRiskBanner, @@ -101,7 +101,8 @@ export function PartnerInfoCards({ }: PartnerInfoCardsProps) { const { id: workspaceId, slug: workspaceSlug, plan } = useWorkspace(); - const { canCreateReferralReward } = getPlanCapabilities(plan); + const { canCreateReferralReward, canManageFraudEvents } = + getPlanCapabilities(plan); const isEnrolled = type === "enrolled" || type === undefined; const isNetwork = type === "network"; @@ -374,9 +375,17 @@ export function PartnerInfoCards({ {partner && isEnrolled && showApplicationRiskAnalysis && ( )} - {partner && isEnrolled && showApplicationRiskAnalysis && ( - - )} + {partner && + isEnrolled && + showApplicationRiskAnalysis && + canManageFraudEvents && ( +
+

+ Network activity +

+ +
+ )}
diff --git a/apps/web/ui/partners/payouts/bank-account-requirements-modal.tsx b/apps/web/ui/partners/payouts/bank-account-requirements-modal.tsx index b002e352bf1..85db54bd74a 100644 --- a/apps/web/ui/partners/payouts/bank-account-requirements-modal.tsx +++ b/apps/web/ui/partners/payouts/bank-account-requirements-modal.tsx @@ -40,8 +40,8 @@ function BankAccountRequirementsModal({
-
- +
+

If your bank account does not meet these requirements, payouts may be delayed or rejected.