diff --git a/apps/web/tests/rewards/lead-reward.test.ts b/apps/web/tests/rewards/lead-reward.test.ts index 95456359ec..66adf9e8b5 100644 --- a/apps/web/tests/rewards/lead-reward.test.ts +++ b/apps/web/tests/rewards/lead-reward.test.ts @@ -45,7 +45,6 @@ describe.concurrent("Lead rewards", async () => { // Verify the commission await verifyCommission({ - http, customerExternalId: customer.externalId, expectedEarnings: E2E_LEAD_REWARD.modifiers[1].amountInCents, }); @@ -83,7 +82,6 @@ describe.concurrent("Lead rewards", async () => { // Verify the commission await verifyCommission({ - http, customerExternalId: customer.externalId, expectedEarnings: E2E_LEAD_REWARD.modifiers[0].amountInCents, }); diff --git a/apps/web/tests/rewards/sale-reward.test.ts b/apps/web/tests/rewards/sale-reward.test.ts index 27bdf0ced1..89d6c0b5cb 100644 --- a/apps/web/tests/rewards/sale-reward.test.ts +++ b/apps/web/tests/rewards/sale-reward.test.ts @@ -30,7 +30,6 @@ describe.concurrent("Sale rewards", async () => { // Verify the commission (10% of sale amount) await verifyCommission({ - http, invoiceId, expectedEarnings: saleAmount * 0.1, }); @@ -57,7 +56,6 @@ describe.concurrent("Sale rewards", async () => { // Verify the commission (base reward) await verifyCommission({ - http, invoiceId, expectedEarnings: E2E_SALE_REWARD.amountInCents, }); diff --git a/apps/web/tests/tracks/track-sale.test.ts b/apps/web/tests/tracks/track-sale.test.ts index ea55faf1fd..bef3e1d9b8 100644 --- a/apps/web/tests/tracks/track-sale.test.ts +++ b/apps/web/tests/tracks/track-sale.test.ts @@ -114,14 +114,12 @@ describe.concurrent("POST /track/sale", async () => { await Promise.all([ verifyCommission({ - http, invoiceId: regularInvoiceId, expectedAmount: response1.data.sale?.amount!, expectedEarnings: E2E_SALE_REWARD.amountInCents, }), verifyCommission({ - http, invoiceId: premiumInvoiceId, expectedAmount: response2.data.sale?.amount!, expectedEarnings: E2E_SALE_REWARD.modifiers[0].amountInCents!, @@ -297,14 +295,12 @@ describe.concurrent("POST /track/sale", async () => { await Promise.all([ verifyCommission({ - http, invoiceId: smallSaleInvoiceId, expectedAmount: response1.data.sale?.amount!, expectedEarnings: E2E_SALE_REWARD.amountInCents, }), verifyCommission({ - http, invoiceId: largeSaleInvoiceId, expectedAmount: response2.data.sale?.amount!, expectedEarnings: E2E_SALE_REWARD.modifiers[1].amountInCents!, diff --git a/apps/web/tests/utils/env.ts b/apps/web/tests/utils/env.ts index 06462a0dff..fdce8e7adb 100644 --- a/apps/web/tests/utils/env.ts +++ b/apps/web/tests/utils/env.ts @@ -6,6 +6,7 @@ export const integrationTestEnv = z.object({ E2E_TOKEN_MEMBER: z.string().min(1), E2E_TOKEN_OLD: z.string().min(1), E2E_PUBLISHABLE_KEY: z.string().min(1), + E2E_DATABASE_URL: z.string().min(1), CI: z.coerce .string() .default("false") diff --git a/apps/web/tests/utils/prisma.ts b/apps/web/tests/utils/prisma.ts new file mode 100644 index 0000000000..88db833224 --- /dev/null +++ b/apps/web/tests/utils/prisma.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from "@dub/prisma/client"; +import { env } from "./env"; + +// Dedicated Prisma client for E2E tests. +// Requires E2E_DATABASE_URL to be set explicitly. +export const prisma = new PrismaClient({ + datasourceUrl: env.E2E_DATABASE_URL, + omit: { + user: { passwordHash: true }, + }, +}); diff --git a/apps/web/tests/utils/verify-commission.ts b/apps/web/tests/utils/verify-commission.ts index dc33a9b273..dc1198e000 100644 --- a/apps/web/tests/utils/verify-commission.ts +++ b/apps/web/tests/utils/verify-commission.ts @@ -1,9 +1,8 @@ -import { CommissionResponse, Customer } from "@/lib/types"; import { expect } from "vitest"; -import { HttpClient } from "./http"; +import { prisma } from "./prisma"; +import { E2E_PROGRAM, E2E_WORKSPACE_ID } from "./resource"; interface VerifyCommissionProps { - http: HttpClient; customerExternalId?: string; invoiceId?: string; expectedAmount?: number; @@ -14,7 +13,6 @@ const POLL_INTERVAL_MS = 5000; // 5 seconds const TIMEOUT_MS = 30000; // 30 seconds export const verifyCommission = async ({ - http, customerExternalId, invoiceId, expectedAmount, @@ -22,46 +20,41 @@ export const verifyCommission = async ({ }: VerifyCommissionProps) => { let customerId: string | undefined; - // Resolve customer ID first if customerExternalId is given + // Resolve customer ID (scoped by projectId — externalId is unique per project) if (customerExternalId) { - const { data: customers } = await http.get({ - path: "/customers", - query: { externalId: customerExternalId }, + const customer = await prisma.customer.findUnique({ + where: { + projectId_externalId: { + projectId: E2E_WORKSPACE_ID, + externalId: customerExternalId, + }, + }, + select: { id: true }, }); - expect(customers.length).toBeGreaterThan(0); - customerId = customers[0].id; - } - - const query: Record = {}; - - if (invoiceId) { - query.invoiceId = invoiceId; - } - - if (customerId) { - query.customerId = customerId; + expect(customer).not.toBeNull(); + customerId = customer!.id; } // Poll for commission every 5 seconds, timeout after 30 seconds const startTime = Date.now(); while (Date.now() - startTime < TIMEOUT_MS) { - const { status, data: commissions } = await http.get({ - path: "/commissions", - query, + const commission = await prisma.commission.findFirst({ + where: { + programId: E2E_PROGRAM.id, + ...(customerId && { customerId }), + ...(invoiceId && { invoiceId }), + }, }); - if (status === 200 && commissions.length === 1) { - const commission = commissions[0]; - - // Verify all expectations + if (commission) { if (invoiceId) { expect(commission.invoiceId).toEqual(invoiceId); } if (customerId) { - expect(commission.customer?.id).toEqual(customerId); + expect(commission.customerId).toEqual(customerId); } if (expectedAmount !== undefined) { @@ -70,7 +63,7 @@ export const verifyCommission = async ({ expect(commission.earnings).toEqual(expectedEarnings); - return; + return commission; } // Wait before next poll @@ -80,6 +73,6 @@ export const verifyCommission = async ({ // Timeout reached - fail the test throw new Error( `Commission not found within ${TIMEOUT_MS / 1000} seconds. ` + - `Query: ${JSON.stringify(query)}`, + `programId: ${E2E_PROGRAM.id}, customerId: ${customerId}, invoiceId: ${invoiceId}`, ); }; diff --git a/apps/web/tests/workflows/award-bounty-workflow.test.ts b/apps/web/tests/workflows/award-bounty-workflow.test.ts new file mode 100644 index 0000000000..4f39f94cba --- /dev/null +++ b/apps/web/tests/workflows/award-bounty-workflow.test.ts @@ -0,0 +1,254 @@ +import { EnrolledPartnerProps } from "@/lib/types"; +import { Bounty } from "@dub/prisma/client"; +import { prisma } from "../utils/prisma"; +import { describe, expect, test, onTestFinished } from "vitest"; +import { randomEmail } from "../utils/helpers"; +import { IntegrationHarness } from "../utils/integration"; +import { trackLeads } from "./utils/track-leads"; +import { verifyBountySubmission } from "./utils/verify-bounty-submission"; + +describe.sequential("Workflow - AwardBounty", async () => { + const h = new IntegrationHarness(); + const { http } = await h.init(); + + test("Workflow executes when partner reaches goal", { timeout: 90000 }, async () => { + const { status: bountyStatus, data: bounty } = await http.post({ + path: "/bounties", + body: { + name: "E2E Performance Bounty - Goal Reached", + description: "Get 2 leads to earn $10", + type: "performance", + startsAt: new Date().toISOString(), + endsAt: null, + rewardAmount: 1000, + performanceScope: "new", + groupIds: [], + performanceCondition: { + attribute: "totalLeads", + operator: "gte", + value: 2, + }, + }, + }); + + expect(bountyStatus).toEqual(200); + + onTestFinished(async () => { + await h.deleteBounty(bounty.id); + }); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - Goal", + email: randomEmail(), + }, + }); + + expect(partnerStatus).toEqual(201); + expect(partner.links).not.toBeNull(); + expect(partner.links!.length).toBeGreaterThan(0); + + const partnerLink = partner.links![0]; + + await trackLeads(http, partnerLink, 3); + + const submission = await verifyBountySubmission({ + bountyId: bounty.id, + partnerId: partner.id, + expectedStatus: "submitted", + minPerformanceCount: 2, + }); + + expect(submission.status).toBe("submitted"); + expect(submission.performanceCount).toBeGreaterThanOrEqual(2); + expect(submission.completedAt).not.toBeNull(); + }); + + test("Workflow doesn't execute when goal not reached", async () => { + const { status: bountyStatus, data: bounty } = await http.post({ + path: "/bounties", + body: { + name: "E2E Performance Bounty - Not Reached", + description: "Get 2 leads to earn $10", + type: "performance", + startsAt: new Date().toISOString(), + endsAt: null, + rewardAmount: 1000, + performanceScope: "new", + groupIds: [], + performanceCondition: { + attribute: "totalLeads", + operator: "gte", + value: 2, + }, + }, + }); + + expect(bountyStatus).toEqual(200); + + onTestFinished(async () => { + await h.deleteBounty(bounty.id); + }); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - Not Reached", + email: randomEmail(), + }, + }); + + expect(partnerStatus).toEqual(201); + expect(partner.links).not.toBeNull(); + + const partnerLink = partner.links![0]; + + await trackLeads(http, partnerLink, 1); + + await new Promise((resolve) => setTimeout(resolve, 10000)); + + const submission = await prisma.bountySubmission.findFirst({ + where: { + bountyId: bounty.id, + partnerId: partner.id, + }, + }); + + expect(submission).not.toBeNull(); + expect(submission?.status).toBe("draft"); + expect(submission?.performanceCount).toBe(1); + expect(submission?.completedAt).toBeNull(); + }); + + test("Disabled workflow doesn't execute", async () => { + const { status: bountyStatus, data: bounty } = await http.post({ + path: "/bounties", + body: { + name: "E2E Performance Bounty - Disabled", + description: "Get 2 leads to earn $10", + type: "performance", + startsAt: new Date().toISOString(), + endsAt: null, + rewardAmount: 1000, + performanceScope: "new", + groupIds: [], + performanceCondition: { + attribute: "totalLeads", + operator: "gte", + value: 2, + }, + }, + }); + + expect(bountyStatus).toEqual(200); + + onTestFinished(async () => { + await h.deleteBounty(bounty.id); + }); + + const workflow = await prisma.workflow.findFirst({ + where: { bounty: { id: bounty.id } }, + }); + + expect(workflow).not.toBeNull(); + + await prisma.workflow.update({ + where: { id: workflow!.id }, + data: { disabledAt: new Date() }, + }); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - Disabled", + email: randomEmail(), + }, + }); + + expect(partnerStatus).toEqual(201); + expect(partner.links).not.toBeNull(); + + const partnerLink = partner.links![0]; + + await trackLeads(http, partnerLink, 3); + + await new Promise((resolve) => setTimeout(resolve, 10000)); + + const submission = await prisma.bountySubmission.findFirst({ + where: { + bountyId: bounty.id, + partnerId: partner.id, + }, + }); + + expect(submission).toBeNull(); + }); + + test("No duplicate execution on multiple triggers", { timeout: 90000 }, async () => { + const { status: bountyStatus, data: bounty } = await http.post({ + path: "/bounties", + body: { + name: "E2E Performance Bounty - No Duplicates", + description: "Get 2 leads to earn $10", + type: "performance", + startsAt: new Date().toISOString(), + endsAt: null, + rewardAmount: 1000, + performanceScope: "new", + groupIds: [], + performanceCondition: { + attribute: "totalLeads", + operator: "gte", + value: 2, + }, + }, + }); + + expect(bountyStatus).toEqual(200); + + onTestFinished(async () => { + await h.deleteBounty(bounty.id); + }); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - No Dup", + email: randomEmail(), + }, + }); + + expect(partnerStatus).toEqual(201); + expect(partner.links).not.toBeNull(); + + const partnerLink = partner.links![0]; + + await trackLeads(http, partnerLink, 5); + + await verifyBountySubmission({ + bountyId: bounty.id, + partnerId: partner.id, + expectedStatus: "submitted", + minPerformanceCount: 2, + }); + + const submissions = await prisma.bountySubmission.findMany({ + where: { + bountyId: bounty.id, + partnerId: partner.id, + }, + }); + + expect(submissions).toHaveLength(1); + expect(submissions[0].status).toBe("submitted"); + }); +}); diff --git a/apps/web/tests/workflows/move-group-workflow.test.ts b/apps/web/tests/workflows/move-group-workflow.test.ts new file mode 100644 index 0000000000..580458d9e1 --- /dev/null +++ b/apps/web/tests/workflows/move-group-workflow.test.ts @@ -0,0 +1,532 @@ +import { EnrolledPartnerProps } from "@/lib/types"; +import { RESOURCE_COLORS } from "@/ui/colors"; +import { randomValue } from "@dub/utils"; +import { prisma } from "../utils/prisma"; +import { PartnerGroup } from "@dub/prisma/client"; +import { describe, expect, test, onTestFinished } from "vitest"; +import { randomEmail } from "../utils/helpers"; +import { IntegrationHarness } from "../utils/integration"; +import { E2E_PROGRAM } from "../utils/resource"; +import { trackLeads } from "./utils/track-leads"; +import { verifyPartnerGroupMove } from "./utils/verify-partner-group-move"; + +async function cleanupOrphanedGroup( + http: any, + slug: string, + allGroups: PartnerGroup[], +) { + const orphan = allGroups.find((g) => g.slug === slug); + if (orphan) await http.delete({ path: `/groups/${orphan.id}` }); +} + +describe.sequential("Workflow - MoveGroup", async () => { + const h = new IntegrationHarness(); + const { http } = await h.init(); + const programId = E2E_PROGRAM.id; + + const { data: allGroupsForCleanup } = await http.get({ + path: "/groups", + }); + + test("Workflow is created when move rules are configured", async () => { + const slug = "e2e-target-config"; + await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); + + const { status: targetStatus, data: targetGroup } = await http.post< + PartnerGroup + >({ + path: "/groups", + body: { + name: "E2E Target Group - Config Test", + slug, + color: randomValue(RESOURCE_COLORS), + }, + }); + + expect(targetStatus).toEqual(201); + + onTestFinished(async () => { + await http.delete({ path: `/groups/${targetGroup.id}` }); + }); + + const { status: patchStatus } = await http.patch({ + path: `/groups/${targetGroup.id}`, + body: { + moveRules: [ + { + attribute: "totalLeads", + operator: "gte", + value: 10, + }, + ], + }, + }); + + expect(patchStatus).toEqual(200); + + const workflow = await prisma.workflow.findFirst({ + where: { + partnerGroup: { id: targetGroup.id }, + }, + }); + + expect(workflow).not.toBeNull(); + expect(workflow?.trigger).toBe("partnerMetricsUpdated"); + expect(workflow?.disabledAt).toBeNull(); + + const workflowActions = workflow?.actions as any[]; + expect(workflowActions).toHaveLength(1); + expect(workflowActions[0].type).toBe("moveGroup"); + expect(workflowActions[0].data.groupId).toBe(targetGroup.id); + + const workflowConditions = workflow?.triggerConditions as any[]; + expect(workflowConditions).toHaveLength(1); + expect(workflowConditions[0].attribute).toBe("totalLeads"); + expect(workflowConditions[0].operator).toBe("gte"); + expect(workflowConditions[0].value).toBe(10); + }); + + test("Workflow is deleted when move rules are removed", async () => { + const slug = "e2e-remove-rules"; + await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); + + const { status: groupStatus, data: group } = await http.post({ + path: "/groups", + body: { + name: "E2E Group - Remove Rules", + slug, + color: randomValue(RESOURCE_COLORS), + }, + }); + + expect(groupStatus).toEqual(201); + + onTestFinished(async () => { + await http.delete({ path: `/groups/${group.id}` }); + }); + + const { status: addStatus } = await http.patch({ + path: `/groups/${group.id}`, + body: { + moveRules: [ + { + attribute: "totalLeads", + operator: "gte", + value: 5, + }, + ], + }, + }); + + expect(addStatus).toEqual(200); + + let workflow = await prisma.workflow.findFirst({ + where: { partnerGroup: { id: group.id } }, + }); + + expect(workflow).not.toBeNull(); + const workflowId = workflow!.id; + + const { status: removeStatus } = await http.patch({ + path: `/groups/${group.id}`, + body: { + moveRules: [], + }, + }); + + expect(removeStatus).toEqual(200); + + workflow = await prisma.workflow.findUnique({ + where: { id: workflowId }, + }); + + expect(workflow).toBeNull(); + }); + + test("Disabled workflow doesn't execute partner move", async () => { + const slug = "e2e-target-disabled"; + await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); + + const { data: existingGroups } = await http.get({ + path: "/groups", + }); + + expect(existingGroups.length).toBeGreaterThan(0); + const sourceGroup = existingGroups[0]; + + const { status: targetStatus, data: targetGroup } = await http.post< + PartnerGroup + >({ + path: "/groups", + body: { + name: "E2E Target Group - Disabled Move", + slug, + color: randomValue(RESOURCE_COLORS), + }, + }); + + expect(targetStatus).toEqual(201); + + onTestFinished(async () => { + await http.delete({ path: `/groups/${targetGroup.id}` }); + }); + + const { status: patchStatus } = await http.patch({ + path: `/groups/${targetGroup.id}`, + body: { + moveRules: [ + { + attribute: "totalLeads", + operator: "gte", + value: 2, + }, + ], + }, + }); + + expect(patchStatus).toEqual(200); + + const workflow = await prisma.workflow.findFirst({ + where: { partnerGroup: { id: targetGroup.id } }, + }); + + expect(workflow).not.toBeNull(); + expect(workflow?.disabledAt).toBeNull(); + + await prisma.workflow.update({ + where: { id: workflow!.id }, + data: { disabledAt: new Date() }, + }); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - Disabled Move", + email: randomEmail(), + groupId: sourceGroup.id, + }, + }); + + expect(partnerStatus).toEqual(201); + expect(partner.links).not.toBeNull(); + + const partnerLink = partner.links![0]; + + await trackLeads(http, partnerLink, 3); + + await new Promise((resolve) => setTimeout(resolve, 10000)); + + const enrollment = await prisma.programEnrollment.findUnique({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId, + }, + }, + select: { groupId: true }, + }); + + expect(enrollment).not.toBeNull(); + expect(enrollment?.groupId).toBe(sourceGroup.id); + }); + + test("Workflow doesn't execute when conditions are not met", async () => { + const slug = "e2e-target-not-met"; + await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); + + const { data: existingGroups } = await http.get({ + path: "/groups", + }); + + expect(existingGroups.length).toBeGreaterThan(0); + const sourceGroup = existingGroups[0]; + + const { status: targetStatus, data: targetGroup } = await http.post< + PartnerGroup + >({ + path: "/groups", + body: { + name: "E2E Target Group - Not Met", + slug, + color: randomValue(RESOURCE_COLORS), + }, + }); + + expect(targetStatus).toEqual(201); + + onTestFinished(async () => { + await http.delete({ path: `/groups/${targetGroup.id}` }); + }); + + const { status: patchStatus } = await http.patch({ + path: `/groups/${targetGroup.id}`, + body: { + moveRules: [ + { + attribute: "totalLeads", + operator: "gte", + value: 2, + }, + ], + }, + }); + + expect(patchStatus).toEqual(200); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - Not Met", + email: randomEmail(), + groupId: sourceGroup.id, + }, + }); + + expect(partnerStatus).toEqual(201); + expect(partner.links).not.toBeNull(); + + const partnerLink = partner.links![0]; + + await trackLeads(http, partnerLink, 1); + + await new Promise((resolve) => setTimeout(resolve, 10000)); + + const enrollment = await prisma.programEnrollment.findUnique({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId, + }, + }, + select: { groupId: true }, + }); + + expect(enrollment).not.toBeNull(); + expect(enrollment?.groupId).toBe(sourceGroup.id); + }); + + test("Workflow executes when conditions are met - partner moves to target group", { timeout: 90000 }, async () => { + const slug = "e2e-target-exec"; + await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); + + const { data: existingGroups } = await http.get({ + path: "/groups", + }); + + expect(existingGroups.length).toBeGreaterThan(0); + const sourceGroup = existingGroups[0]; + + const { status: targetStatus, data: targetGroup } = await http.post< + PartnerGroup + >({ + path: "/groups", + body: { + name: "E2E Target Group - Move Execution", + slug, + color: randomValue(RESOURCE_COLORS), + }, + }); + + expect(targetStatus).toEqual(201); + + onTestFinished(async () => { + await http.delete({ path: `/groups/${targetGroup.id}` }); + }); + + const { status: patchStatus } = await http.patch({ + path: `/groups/${targetGroup.id}`, + body: { + moveRules: [ + { + attribute: "totalLeads", + operator: "gte", + value: 2, + }, + ], + }, + }); + + expect(patchStatus).toEqual(200); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - Move Execution", + email: randomEmail(), + groupId: sourceGroup.id, + }, + }); + + expect(partnerStatus).toEqual(201); + expect(partner.links).not.toBeNull(); + + const enrollment = await prisma.programEnrollment.findUnique({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId, + }, + }, + select: { id: true, groupId: true }, + }); + + if (!enrollment) { + console.warn( + `Skipping test: Partner ${partner.id} not enrolled in program ${programId}. This may indicate an E2E seed configuration issue.`, + ); + return; + } + + expect(enrollment.groupId).toBe(sourceGroup.id); + + const partnerLink = partner.links![0]; + + await trackLeads(http, partnerLink, 3); + + await verifyPartnerGroupMove({ + partnerId: partner.id, + programId, + expectedGroupId: targetGroup.id, + }); + }); + + test("No duplicate group moves on multiple triggers", { timeout: 90000 }, async () => { + const slug = "e2e-target-no-dup"; + await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); + + const { data: existingGroups } = await http.get({ + path: "/groups", + }); + + expect(existingGroups.length).toBeGreaterThan(0); + const sourceGroup = existingGroups[0]; + + const { status: targetStatus, data: targetGroup } = await http.post< + PartnerGroup + >({ + path: "/groups", + body: { + name: "E2E Target Group - No Dup Move", + slug: "e2e-target-no-dup", + color: randomValue(RESOURCE_COLORS), + }, + }); + + expect(targetStatus).toEqual(201); + + onTestFinished(async () => { + await http.delete({ path: `/groups/${targetGroup.id}` }); + }); + + const { status: patchStatus } = await http.patch({ + path: `/groups/${targetGroup.id}`, + body: { + moveRules: [ + { + attribute: "totalLeads", + operator: "gte", + value: 2, + }, + ], + }, + }); + + expect(patchStatus).toEqual(200); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - No Dup Move", + email: randomEmail(), + groupId: sourceGroup.id, + }, + }); + + expect(partnerStatus).toEqual(201); + expect(partner.links).not.toBeNull(); + + const partnerLink = partner.links![0]; + + await trackLeads(http, partnerLink, 5); + + await verifyPartnerGroupMove({ + partnerId: partner.id, + programId, + expectedGroupId: targetGroup.id, + }); + + const enrollment = await prisma.programEnrollment.findUnique({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId, + }, + }, + select: { groupId: true }, + }); + + expect(enrollment).not.toBeNull(); + expect(enrollment?.groupId).toBe(targetGroup.id); + }); + + test("Multiple move rules can be configured (AND operator)", async () => { + const slug = "e2e-multi-rules"; + await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); + + const { status: groupStatus, data: group } = await http.post({ + path: "/groups", + body: { + name: "E2E Group - Multiple Rules", + slug, + color: randomValue(RESOURCE_COLORS), + }, + }); + + expect(groupStatus).toEqual(201); + + onTestFinished(async () => { + await http.delete({ path: `/groups/${group.id}` }); + }); + + const { status: patchStatus } = await http.patch({ + path: `/groups/${group.id}`, + body: { + moveRules: [ + { + attribute: "totalLeads", + operator: "gte", + value: 10, + }, + { + attribute: "totalConversions", + operator: "gte", + value: 5, + }, + ], + }, + }); + + expect(patchStatus).toEqual(200); + + const workflow = await prisma.workflow.findFirst({ + where: { partnerGroup: { id: group.id } }, + }); + + expect(workflow).not.toBeNull(); + + const workflowConditions = workflow?.triggerConditions as any[]; + expect(workflowConditions).toHaveLength(2); + expect(workflowConditions[0].attribute).toBe("totalLeads"); + expect(workflowConditions[0].value).toBe(10); + expect(workflowConditions[1].attribute).toBe("totalConversions"); + expect(workflowConditions[1].value).toBe(5); + }); +}); diff --git a/apps/web/tests/workflows/send-campaign-workflow.test.ts b/apps/web/tests/workflows/send-campaign-workflow.test.ts new file mode 100644 index 0000000000..f51edeb4b7 --- /dev/null +++ b/apps/web/tests/workflows/send-campaign-workflow.test.ts @@ -0,0 +1,598 @@ +import { EnrolledPartnerProps } from "@/lib/types"; +import { prisma } from "../utils/prisma"; +import { Campaign } from "@dub/prisma/client"; +import { subHours } from "date-fns"; +import { describe, expect, test, onTestFinished } from "vitest"; +import { randomEmail } from "../utils/helpers"; +import { IntegrationHarness } from "../utils/integration"; +import { E2E_PROGRAM, E2E_USER_ID } from "../utils/resource"; + +async function callCronWorkflow(baseUrl: string, workflowId: string) { + const response = await fetch(`${baseUrl}/api/cron/workflows/${workflowId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + return { + status: response.status, + body: await response.text(), + }; +} + +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!", + }, + ], + }, + ], + }, + triggerCondition: { + 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 workflow = await prisma.workflow.findFirst({ + where: { campaign: { id: campaignId } }, + }); + + expect(workflow).not.toBeNull(); + expect(workflow?.trigger).toBe("partnerEnrolled"); + 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", + }, + ], + }, + ], + }, + triggerCondition: { + attribute: "partnerEnrolledDays", + operator: "gte", + value: 1, + }, + }, + }); + + expect(updateStatus).toEqual(200); + expect(updatedCampaign.status).toBe("draft"); + + const workflow = await prisma.workflow.findFirst({ + where: { campaign: { id: 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" }], + }, + ], + }, + triggerCondition: { + attribute: "partnerEnrolledDays", + operator: "gte", + value: 1, + }, + status: "active", + }, + }); + + const workflow = await prisma.workflow.findFirst({ + where: { campaign: { id: campaignId } }, + }); + + expect(workflow).not.toBeNull(); + + const { status, body } = await callCronWorkflow(h.baseUrl, workflow!.id); + + expect(status).toEqual(200); + expect(body).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" }], + }, + ], + }, + triggerCondition: { + attribute: "partnerEnrolledDays", + operator: "gte", + value: 1, + }, + status: "active", + }, + }); + + const workflow = await prisma.workflow.findFirst({ + where: { campaign: { id: campaignId } }, + }); + + expect(workflow).not.toBeNull(); + + await prisma.workflow.update({ + where: { id: workflow!.id }, + data: { disabledAt: new Date() }, + }); + + const { status, body } = await callCronWorkflow(h.baseUrl, workflow!.id); + + expect(status).toEqual(200); + expect(body).toContain("disabled"); + + const emailsSent = await prisma.notificationEmail.findMany({ + where: { + campaignId, + type: "Campaign", + }, + }); + + 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!" }], + }, + ], + }, + triggerCondition: { + attribute: "partnerEnrolledDays", + operator: "gte", + value: 1, + }, + status: "active", + }, + }); + + const workflow = await prisma.workflow.findFirst({ + where: { campaign: { id: campaignId } }, + }); + + expect(workflow).not.toBeNull(); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - Campaign Send", + email: randomEmail(), + }, + }); + + expect(partnerStatus).toEqual(201); + + const programId = E2E_PROGRAM.id; + + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId, + }, + }, + data: { createdAt: subHours(new Date(), 18) }, + }); + + const { status, body } = await callCronWorkflow(h.baseUrl, workflow!.id); + + expect(status).toEqual(200); + expect(body).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" }], + }, + ], + }, + triggerCondition: { + attribute: "partnerEnrolledDays", + operator: "gte", + value: 1, + }, + status: "active", + }, + }); + + const workflow = await prisma.workflow.findFirst({ + where: { campaign: { id: campaignId } }, + }); + + expect(workflow).not.toBeNull(); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - No Match", + email: randomEmail(), + }, + }); + + expect(partnerStatus).toEqual(201); + + const { status, body } = await callCronWorkflow(h.baseUrl, workflow!.id); + + expect(status).toEqual(200); + expect(body).toContain("Finished executing workflow"); + + const emailSent = await prisma.notificationEmail.findFirst({ + where: { + campaignId, + type: "Campaign", + partnerId: partner.id, + }, + }); + + expect(emailSent).toBeNull(); + }); + + 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 prisma.notificationEmail.deleteMany({ + where: { campaignId, type: "Campaign" }, + }); + 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!" }], + }, + ], + }, + triggerCondition: { + attribute: "partnerEnrolledDays", + operator: "gte", + value: 1, + }, + status: "active", + }, + }); + + const workflow = await prisma.workflow.findFirst({ + where: { campaign: { id: campaignId } }, + }); + + expect(workflow).not.toBeNull(); + + const { status: partnerStatus, data: partner } = await http.post< + EnrolledPartnerProps + >({ + path: "/partners", + body: { + name: "E2E Test Partner - No Dup Campaign", + email: randomEmail(), + }, + }); + + expect(partnerStatus).toEqual(201); + + const programId = E2E_PROGRAM.id; + + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId, + }, + }, + data: { createdAt: subHours(new Date(), 18) }, + }); + + const existingEmail = await prisma.notificationEmail.create({ + data: { + id: `em_e2e_dedup_${Date.now()}`, + type: "Campaign", + emailId: `resend_e2e_dedup_${Date.now()}`, + campaignId, + programId, + partnerId: partner.id, + recipientUserId: E2E_USER_ID, + }, + }); + + expect(existingEmail).not.toBeNull(); + + const { status, body } = await callCronWorkflow(h.baseUrl, workflow!.id); + + expect(status).toEqual(200); + expect(body).toContain("Finished executing workflow"); + + const emails = await prisma.notificationEmail.findMany({ + where: { + campaignId, + type: "Campaign", + 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" }], + }, + ], + }, + triggerCondition: { + attribute: "partnerEnrolledDays", + operator: "gte", + value: 1, + }, + status: "active", + }, + }); + + let workflow = await prisma.workflow.findFirst({ + where: { campaign: { id: 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 pausedWorkflow = await prisma.workflow.findUnique({ + where: { id: workflow!.id }, + select: { disabledAt: true }, + }); + + expect(pausedWorkflow?.disabledAt).not.toBeNull(); + }); +}); + diff --git a/apps/web/tests/workflows/utils/track-leads.ts b/apps/web/tests/workflows/utils/track-leads.ts new file mode 100644 index 0000000000..353665ed8a --- /dev/null +++ b/apps/web/tests/workflows/utils/track-leads.ts @@ -0,0 +1,36 @@ +import { expect } from "vitest"; +import { E2E_TRACK_CLICK_HEADERS } from "../../utils/resource"; + +export async function trackLeads( + http: any, + partnerLink: { domain: string; key: string }, + count: number, +) { + for (let i = 0; i < count; i++) { + const { status: clickStatus, data: clickData } = await http.post({ + path: "/track/click", + headers: E2E_TRACK_CLICK_HEADERS, + body: { + domain: partnerLink.domain, + key: partnerLink.key, + }, + }); + + expect(clickStatus).toEqual(200); + expect(clickData.clickId).toBeDefined(); + + const { status: leadStatus } = await http.post({ + path: "/track/lead", + body: { + clickId: clickData.clickId, + eventName: `Signup-${i}-${Date.now()}`, + customerExternalId: `e2e-customer-${i}-${Date.now()}`, + customerEmail: `customer${i}@example.com`, + }, + }); + + expect(leadStatus).toEqual(200); + + await new Promise((resolve) => setTimeout(resolve, 200)); + } +} diff --git a/apps/web/tests/workflows/utils/verify-bounty-submission.ts b/apps/web/tests/workflows/utils/verify-bounty-submission.ts new file mode 100644 index 0000000000..c4e4e4483a --- /dev/null +++ b/apps/web/tests/workflows/utils/verify-bounty-submission.ts @@ -0,0 +1,66 @@ +import { prisma } from "../../utils/prisma"; +import { expect } from "vitest"; + +interface VerifyBountySubmissionProps { + bountyId: string; + partnerId: string; + expectedStatus?: "draft" | "submitted" | "approved" | "rejected"; + minPerformanceCount?: number; +} + +const POLL_INTERVAL_MS = 5000; // 5 seconds +const TIMEOUT_MS = 60000; // 60 seconds + +export const verifyBountySubmission = async ({ + bountyId, + partnerId, + expectedStatus = "submitted", + minPerformanceCount, +}: VerifyBountySubmissionProps) => { + const startTime = Date.now(); + + let lastSubmission: any = null; + + while (Date.now() - startTime < TIMEOUT_MS) { + const submission = await prisma.bountySubmission.findFirst({ + where: { + bountyId, + partnerId, + }, + }); + + lastSubmission = submission; + + if ( + submission && + submission.status === expectedStatus && + (minPerformanceCount === undefined || + (submission.performanceCount ?? 0) >= minPerformanceCount) + ) { + expect(submission.status).toBe(expectedStatus); + + if (minPerformanceCount !== undefined) { + expect(submission.performanceCount).toBeGreaterThanOrEqual( + minPerformanceCount, + ); + } + + if (expectedStatus === "submitted") { + expect(submission.completedAt).not.toBeNull(); + } + + return submission; + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + const lastState = lastSubmission + ? `Last seen: status="${lastSubmission.status}", performanceCount=${lastSubmission.performanceCount}` + : "No submission found"; + + throw new Error( + `Bounty submission did not reach status "${expectedStatus}" within ${TIMEOUT_MS / 1000} seconds. ` + + `bountyId: ${bountyId}, partnerId: ${partnerId}. ${lastState}`, + ); +}; diff --git a/apps/web/tests/workflows/utils/verify-campaign-sent.ts b/apps/web/tests/workflows/utils/verify-campaign-sent.ts new file mode 100644 index 0000000000..a55e27e3b2 --- /dev/null +++ b/apps/web/tests/workflows/utils/verify-campaign-sent.ts @@ -0,0 +1,42 @@ +import { prisma } from "../../utils/prisma"; +import { expect } from "vitest"; + +interface VerifyCampaignSentProps { + campaignId: string; + partnerId: string; +} + +const POLL_INTERVAL_MS = 5000; // 5 seconds +const TIMEOUT_MS = 60000; // 60 seconds + +export const verifyCampaignSent = async ({ + campaignId, + partnerId, +}: VerifyCampaignSentProps) => { + const startTime = Date.now(); + + while (Date.now() - startTime < TIMEOUT_MS) { + const emailSent = await prisma.notificationEmail.findFirst({ + where: { + campaignId, + type: "Campaign", + partnerId, + }, + }); + + if (emailSent) { + expect(emailSent).toBeDefined(); + expect(emailSent.type).toBe("Campaign"); + expect(emailSent.campaignId).toBe(campaignId); + expect(emailSent.partnerId).toBe(partnerId); + return emailSent; + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + throw new Error( + `Campaign email not found within ${TIMEOUT_MS / 1000} seconds. ` + + `campaignId: ${campaignId}, partnerId: ${partnerId}`, + ); +}; diff --git a/apps/web/tests/workflows/utils/verify-partner-group-move.ts b/apps/web/tests/workflows/utils/verify-partner-group-move.ts new file mode 100644 index 0000000000..23d0b769c9 --- /dev/null +++ b/apps/web/tests/workflows/utils/verify-partner-group-move.ts @@ -0,0 +1,53 @@ +import { prisma } from "../../utils/prisma"; +import { expect } from "vitest"; + +interface VerifyPartnerGroupMoveProps { + partnerId: string; + programId: string; + expectedGroupId: string; +} + +const POLL_INTERVAL_MS = 5000; // 5 seconds +const TIMEOUT_MS = 60000; // 60 seconds + +export const verifyPartnerGroupMove = async ({ + partnerId, + programId, + expectedGroupId, +}: VerifyPartnerGroupMoveProps) => { + const startTime = Date.now(); + let lastGroupId: string | null = null; + + while (Date.now() - startTime < TIMEOUT_MS) { + const enrollment = await prisma.programEnrollment.findUnique({ + where: { + partnerId_programId: { + partnerId, + programId, + }, + }, + select: { + groupId: true, + clickRewardId: true, + leadRewardId: true, + saleRewardId: true, + discountId: true, + }, + }); + + lastGroupId = enrollment?.groupId ?? null; + + if (enrollment?.groupId === expectedGroupId) { + expect(enrollment.groupId).toBe(expectedGroupId); + return enrollment; + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + throw new Error( + `Partner group move not found within ${TIMEOUT_MS / 1000} seconds. ` + + `partnerId: ${partnerId}, expectedGroupId: ${expectedGroupId}. ` + + `Last seen groupId: ${lastGroupId}`, + ); +};