Browse documentation

Test Specs

RankFlow AI — Onboarding Flow E2E Test Specification

// src/tests/mocks/google-oauth.ts

docs/test-specs/TEST-onboarding-e2e.md
On this page

Version: 1.0.0
Date: 2026-06-13
Scope: Complete customer onboarding flow from signup to first GBP post + admin notification
Target: 80% completion rate from signup to first GBP post published
Source Spec: docs/business_flow_map.md Section 3
Test Plan: docs/test-plan.md Section 3


1. Mock Setup & External Service Stubs#

1.1 Google OAuth Mock#

// src/__tests__/mocks/google-oauth.ts
import { http, HttpResponse } from "msw";

export const googleOAuthHandlers = [
  // OAuth token exchange
  http.post("https://oauth2.googleapis.com/token", async () => {
    return HttpResponse.json({
      access_token: "mock_access_token_12345",
      refresh_token: "mock_refresh_token_67890",
      expires_in: 3600,
      token_type: "Bearer",
      scope: "https://www.googleapis.com/auth/business.manage",
    });
  }),

  // Google People API
  http.get("https://people.googleapis.com/v1/people/me", () => {
    return HttpResponse.json({
      resourceName: "people/mock123",
      names: [{ displayName: "Dr. Smith" }],
      emailAddresses: [{ value: "dr.smith@example.com" }],
    });
  }),

  // GBP Business Information API
  http.get("https://mybusinessbusinessinformation.googleapis.com/v1/accounts/-/locations", () => {
    return HttpResponse.json({
      locations: [
        {
          name: "locations/123456789",
          locationName: "Dr. Smith Dental Clinic",
          primaryPhone: "+919876543210",
          address: {
            regionCode: "IN",
            locality: "Kochi",
            administrativeArea: "Kerala",
            postalCode: "682001",
            addressLines: ["123 Main Road"],
          },
          primaryCategory: { displayName: "Dentist" },
          websiteUri: "https://dr-smith-dental.rankflow.in",
        },
      ],
    });
  }),

  // GBP Accounts API
  http.get("https://mybusinessaccountmanagement.googleapis.com/v1/accounts", () => {
    return HttpResponse.json({
      accounts: [{ name: "accounts/123456789", accountName: "Dr. Smith Dental Clinic" }],
    });
  }),

  // GBP Posts API
  http.post("https://mybusiness.googleapis.com/v4/:name/localPosts", () => {
    return HttpResponse.json({ name: "localPosts/abc123", searchUrl: "https://g.page/dr-smith-dental/post/abc123" });
  }),

  // OAuth access denied simulation
  http.get("/api/webhooks/google/oauth-callback", ({ request }) => {
    const url = new URL(request.url);
    if (url.searchParams.get("error") === "access_denied") {
      return HttpResponse.json({ error: "access_denied" }, { status: 400 });
    }
    return HttpResponse.json({ code: "mock_auth_code_123" });
  }),
];

1.2 Stripe / Razorpay Mock#

// src/__tests__/mocks/payment-provider.ts
import { http, HttpResponse } from "msw";

export const paymentProviderHandlers = [
  // Stripe — create customer
  http.post("https://api.stripe.com/v1/customers", () => {
    return HttpResponse.json({
      id: "cus_mock_12345",
      email: "dr.smith@example.com",
      created: Math.floor(Date.now() / 1000),
    });
  }),

  // Stripe — create subscription
  http.post("https://api.stripe.com/v1/subscriptions", () => {
    return HttpResponse.json({
      id: "sub_mock_67890",
      status: "trialing",
      trial_end: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
      customer: "cus_mock_12345",
    });
  }),

  // Stripe — webhook events
  http.post("/api/webhooks/stripe", async ({ request }) => {
    const payload = await request.json();
    return HttpResponse.json({ received: true, id: payload.id });
  }),

  // Razorpay — create customer
  http.post("https://api.razorpay.com/v1/customers", () => {
    return HttpResponse.json({
      id: "cust_mock_12345",
      email: "dr.smith@example.com",
      contact: "+919876543210",
    });
  }),

  // Razorpay — create subscription
  http.post("https://api.razorpay.com/v1/subscriptions", () => {
    return HttpResponse.json({
      id: "sub_mock_67890",
      status: "active",
      current_start: Math.floor(Date.now() / 1000),
      current_end: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
      customer_id: "cust_mock_12345",
    });
  }),

  // Razorpay — webhook events
  http.post("/api/webhooks/razorpay", async ({ request }) => {
    const payload = await request.json();
    return HttpResponse.json({ received: true, event: payload.event });
  }),
];

1.3 Composio / Zernio Mock#

// src/__tests__/mocks/composio.ts
import { http, HttpResponse } from "msw";

export const composioHandlers = [
  // Composio — initiate connection
  http.post("https://backend.composio.dev/api/v1/connectedAccounts", () => {
    return HttpResponse.json({
      id: "conn_mock_instagram_123",
      integrationId: "instagram",
      status: "ACTIVE",
      appName: "instagram",
    });
  }),

  http.post("https://backend.composio.dev/api/v1/connectedAccounts", () => {
    return HttpResponse.json({
      id: "conn_mock_facebook_456",
      integrationId: "facebook",
      status: "ACTIVE",
      appName: "facebook",
    });
  }),

  // Zernio — schedule post
  http.post("https://api.zernio.com/v1/posts", () => {
    return HttpResponse.json({
      id: "post_mock_789",
      status: "scheduled",
      platform: "instagram",
      scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
    });
  }),
];

1.4 Resend Email Mock#

// src/__tests__/mocks/resend.ts
import { http, HttpResponse } from "msw";

export const resendHandlers = [
  http.post("https://api.resend.com/emails", () => {
    return HttpResponse.json({
      id: "email_mock_12345",
      from: "onboarding@rankflow.in",
      to: "dr.smith@example.com",
      created_at: new Date().toISOString(),
    });
  }),

  // Resend webhook delivery tracking
  http.post("/api/webhooks/resend", async ({ request }) => {
    const payload = await request.json();
    return HttpResponse.json({
      received: true,
      type: payload.type,
      email_id: payload.data?.email_id,
    });
  }),
];

1.5 LLM API Mock (Claude / GPT / Llama)#

// src/__tests__/mocks/llm-router.ts
import { http, HttpResponse } from "msw";

export const llmRouterHandlers = [
  // Claude Sonnet — directory profile content
  http.post("https://api.anthropic.com/v1/messages", () => {
    return HttpResponse.json({
      id: "msg_mock_claude_123",
      content: [
        {
          type: "text",
          text: JSON.stringify({
            hero: {
              h1: "Best Dentist in Kochi — Dr. Smith Dental Clinic",
              subtitle: "Advanced dental care with 15+ years of experience. Book your appointment today.",
              cta: "Book Appointment",
            },
            about: "Dr. Smith leads a team of dental specialists providing comprehensive oral care in Kochi.",
            services: [
              { name: "Teeth Cleaning", description: "Professional dental cleaning for healthy gums" },
              { name: "Root Canal Treatment", description: "Pain-free root canal therapy using latest techniques" },
              { name: "Dental Implants", description: "Permanent tooth replacement with natural-looking results" },
            ],
            faq: [
              { q: "What are your clinic hours?", a: "We are open Monday-Saturday, 9 AM to 7 PM." },
              { q: "Do you accept insurance?", a: "Yes, we accept all major dental insurance plans." },
            ],
            schema: {
              "@context": "https://schema.org",
              "@type": "Dentist",
              name: "Dr. Smith Dental Clinic",
              address: {
                "@type": "PostalAddress",
                addressLocality: "Kochi",
                addressRegion: "Kerala",
                postalCode: "682001",
                addressCountry: "IN",
              },
              telephone: "+919876543210",
            },
          }),
        },
      ],
      model: "claude-sonnet-4-20250514",
      usage: { input_tokens: 1200, output_tokens: 800 },
    });
  }),

  // Claude Haiku — FAQ / meta / bulk
  http.post("https://api.anthropic.com/v1/messages", () => {
    return HttpResponse.json({
      id: "msg_mock_haiku_456",
      content: [{ type: "text", text: "Best Dentist in Kochi | Dr. Smith Dental Clinic — Book Now" }],
      model: "claude-haiku-3-20240307",
      usage: { input_tokens: 400, output_tokens: 60 },
    });
  }),

  // OpenAI GPT — fallback / social / citation descriptions
  http.post("https://api.openai.com/v1/chat/completions", () => {
    return HttpResponse.json({
      id: "chatcmpl_mock_gpt_789",
      choices: [
        {
          message: {
            role: "assistant",
            content: JSON.stringify({
              description: "Dr. Smith Dental Clinic offers comprehensive dental services in Kochi, Kerala. With over 15 years of experience, we specialize in cosmetic dentistry, implants, and root canal treatments. Visit us for personalized, pain-free dental care.",
              keywords: ["dentist kochi", "dental clinic kerala", "root canal treatment"],
            }),
          },
        },
      ],
      model: "gpt-4o-mini",
      usage: { prompt_tokens: 500, completion_tokens: 150 },
    });
  }),

  // Llama 3 — citation descriptions (bulk)
  http.post("https://api.together.xyz/v1/completions", () => {
    return HttpResponse.json({
      id: "together_mock_llama_123",
      choices: [
        {
          text: JSON.stringify({
            directoryId: "justdial",
            title: "Dr. Smith Dental Clinic — Best Dentist in Kochi",
            description: "Looking for a trusted dentist in Kochi? Dr. Smith Dental Clinic offers advanced dental care including implants, root canals, and cosmetic dentistry. 15+ years experience. Book today!",
            keywords: ["dentist kochi", "dental clinic", "root canal"],
          }),
        },
      ],
      model: "meta-llama/Llama-3-70b",
      usage: { prompt_tokens: 300, completion_tokens: 120 },
    });
  }),
];

1.6 Playwright Test Environment Setup#

// e2e/setup/onboarding.setup.ts
import { test as setup } from "@playwright/test";
import { prisma } from "@/lib/db";
import { createMockPractice } from "@/__tests__/factories/practice";
import { createMockUser } from "@/__tests__/factories/user";
import { createMockLocation } from "@/__tests__/factories/location";
import { seedCitationDirectories } from "@/__tests__/seeders/citation-directories";

setup("seed onboarding test environment", async () => {
  // Clean test database
  await prisma.$transaction([
    prisma.consentLog.deleteMany({}),
    prisma.citation.deleteMany({}),
    prisma.socialAccount.deleteMany({}),
    prisma.gbpLocation.deleteMany({}),
    prisma.gbpAccount.deleteMany({}),
    prisma.directoryProfileSection.deleteMany({}),
    prisma.contentPiece.deleteMany({}),
    prisma.scheduledPost.deleteMany({}),
    prisma.location.deleteMany({}),
    prisma.clientProfile.deleteMany({}),
    prisma.user.deleteMany({}),
    prisma.citationDirectory.deleteMany({}),
  ]);

  // Seed 30 citation directories
  await seedCitationDirectories();

  // Verify seed
  const dirCount = await prisma.citationDirectory.count();
  if (dirCount !== 30) throw new Error(`Expected 30 directories, got ${dirCount}`);
});

2. Step 1: Account Creation & Plan Selection#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
signupValidation Email: dr.smith@example.com, Password: Password123 Valid Zod schema src/__tests__/unit/auth.validation.test.ts
signupDuplicateEmail Existing email 409 Conflict with message src/__tests__/unit/auth.validation.test.ts
passwordStrength Password: weak Zod error: min 8 chars, 1 uppercase, 1 number src/__tests__/unit/auth.validation.test.ts

Integration Tests#

Test Setup Action Assertion File
signupCreatesUser Clean DB POST /api/auth/signup User + ClientProfile created with role: CLIENT, status: TRIAL src/__tests__/integration/auth.router.test.ts
signupCapturesUtm UTM params in URL POST /api/auth/signup ClientProfile.utmSource stored src/__tests__/integration/auth.router.test.ts
signupRedirects Valid payload POST /api/auth/signup 302 redirect to /onboarding?step=1 src/__tests__/integration/auth.router.test.ts

E2E Tests#

Flow Steps Expected End State File
signupFlow 1. Visit /signup 2. Select "Standard" plan 3. Fill email, password, practice name 4. Click "Create Account" Redirected to /onboarding?step=1 within 3s; User + ClientProfile records exist e2e/onboarding/step-01-signup.spec.ts

Success Criteria (Binary)#

  • User record created with role: CLIENT, UUID id, bcrypt-hashed password
  • ClientProfile record created with status: TRIAL, plan: STANDARD, onboardingComplete: false
  • UTM parameters captured and stored in ClientProfile.utmSource, .utmMedium, .utmCampaign
  • Customer redirected to /onboarding?step=1 within 3 seconds of successful signup
  • No validation errors displayed inline for valid inputs
  • Duplicate email returns friendly error: "This email is already registered. [Log in]"
  • Password < 8 chars or missing uppercase/number returns inline Zod error

Agent Context (Pre-conditions)#

  • Required DB state: PostgreSQL running, Prisma schema migrated, User and ClientProfile tables empty
  • Required env vars: NEXTAUTH_SECRET, DATABASE_URL, NEXT_PUBLIC_APP_URL=http://localhost:3000
  • Required external mocks: None (pure internal auth)
  • Required test data: Factory createMockUser() + createMockClientProfile()

Verification Commands#

# Run tests for Step 1
pnpm test:unit -- src/__tests__/unit/auth.validation.test.ts
pnpm test:integration -- src/__tests__/integration/auth.router.test.ts
pnpm test:e2e -- e2e/onboarding/step-01-signup.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-01-signup.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";

test("Step 1: Account creation and plan selection", async ({ page }) => {
  await page.goto("/signup?utm_source=google_ads&utm_medium=cpc&utm_campaign=dental_kochi");

  // Select plan
  await page.click('[data-testid="plan-standard"]');
  await page.click('[data-testid="plan-confirm"]');

  // Fill signup form
  await page.fill('[data-testid="email-input"]', "dr.smith@example.com");
  await page.fill('[data-testid="password-input"]', "Password123");
  await page.fill('[data-testid="confirm-password-input"]', "Password123");
  await page.fill('[data-testid="practice-name-input"]', "Dr. Smith Dental Clinic");

  // Submit
  await page.click('[data-testid="create-account-button"]');

  // Assert redirect
  await expect(page).toHaveURL(/\/onboarding\?step=1/, { timeout: 5000 });

  // Assert DB state
  const user = await prisma.user.findFirst({ where: { email: "dr.smith@example.com" } });
  expect(user).not.toBeNull();
  expect(user?.role).toBe("CLIENT");

  const profile = await prisma.clientProfile.findFirst({ where: { userId: user!.id } });
  expect(profile).not.toBeNull();
  expect(profile?.status).toBe("TRIAL");
  expect(profile?.plan).toBe("STANDARD");
  expect(profile?.onboardingComplete).toBe(false);
  expect(profile?.utmSource).toBe("google_ads");
});

3. Step 2: Business Profile Capture Form#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
businessProfileValidation Full valid form payload Zod schema passes src/__tests__/unit/practice.validation.test.ts
phoneFormatValidation Phone: 9876543210 Zod error: must include +91 src/__tests__/unit/practice.validation.test.ts
pinCodeValidation PIN: 12345 Zod error: must be 6 digits src/__tests__/unit/practice.validation.test.ts

Integration Tests#

Test Setup Action Assertion File
practiceUpdate Authenticated user with ClientProfile tRPC practice.update Location record created with isPrimary: true; Practice updated src/__tests__/integration/practice.router.test.ts
autoSaveDraft Partial form data tRPC practice.saveDraft Draft saved to DB with partial fields src/__tests__/integration/practice.router.test.ts

E2E Tests#

Flow Steps Expected End State File
businessProfileFlow 1. Wizard step 1: Business info 2. Step 2: Location & hours 3. Step 3: Services & media 4. Submit Location record with full NAP; Practice updated; directorySlug slug generated e2e/onboarding/step-02-business-profile.spec.ts

Success Criteria (Binary)#

  • All required fields populated and validated (name, category, address, city, state, PIN, phone, email, hours, services, USPs, doctor name)
  • Location record created with isPrimary: true and full NAP data
  • Practice record updated with businessName, category, type
  • DirectorySlug slug auto-generated from business name (e.g., dr-smith-dental-clinic)
  • Invalid phone format (+91 + 10 digits) returns inline error
  • Invalid 6-digit PIN code returns inline error
  • Image > 2MB returns client-side error before upload
  • Form abandonment > 10 min triggers auto-save draft email

Agent Context (Pre-conditions)#

  • Required DB state: User + ClientProfile from Step 1; Location and Practice tables empty
  • Required env vars: DATABASE_URL, UPLOAD_MAX_SIZE=2097152 (2MB)
  • Required external mocks: None
  • Required test data: Factory createMockLocation(), createMockPractice() with full NAP

Verification Commands#

pnpm test:unit -- src/__tests__/unit/practice.validation.test.ts
pnpm test:integration -- src/__tests__/integration/practice.router.test.ts
pnpm test:e2e -- e2e/onboarding/step-02-business-profile.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-02-business-profile.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { createMockUserAndProfile } from "@/__tests__/helpers/auth";

test("Step 2: Business profile capture form", async ({ page }) => {
  const { user, profile } = await createMockUserAndProfile({ plan: "STANDARD" });
  await page.goto("/onboarding?step=1");

  // Step 1: Business Info
  await page.fill('[data-testid="business-name"]', "Dr. Smith Dental Clinic");
  await page.selectOption('[data-testid="business-category"]', "DENTIST");
  await page.fill('[data-testid="doctor-name"]', "Dr. John Smith");
  await page.fill('[data-testid="years-experience"]', "15");
  await page.fill('[data-testid="usps"]', "Advanced dental implants, painless root canals, cosmetic dentistry");
  await page.click('[data-testid="next-step-2"]');

  // Step 2: Location & Hours
  await page.fill('[data-testid="primary-address"]', "123 Main Road, Ernakulam");
  await page.selectOption('[data-testid="city"]', "Kochi");
  await page.fill('[data-testid="pin-code"]', "682001");
  await page.fill('[data-testid="primary-phone"]', "+919876543210");
  await page.fill('[data-testid="business-email"]', "contact@drsmithdental.com");
  await page.fill('[data-testid="hours-monday-open"]', "09:00");
  await page.fill('[data-testid="hours-monday-close"]', "19:00");
  await page.click('[data-testid="next-step-3"]');

  // Step 3: Services & Media
  await page.fill('[data-testid="service-1"]', "Teeth Cleaning");
  await page.fill('[data-testid="service-2"]', "Root Canal Treatment");
  await page.fill('[data-testid="service-3"]', "Dental Implants");
  await page.click('[data-testid="submit-profile"]');

  // Assert completion
  await expect(page).toHaveURL(/\/onboarding\?step=consent/, { timeout: 5000 });

  // Assert DB state
  const location = await prisma.location.findFirst({ where: { practiceId: profile.id } });
  expect(location).not.toBeNull();
  expect(location?.isPrimary).toBe(true);
  expect(location?.phone).toBe("+919876543210");
  expect(location?.city).toBe("Kochi");

  const practice = await prisma.practice.findUnique({ where: { id: profile.id } });
  expect(practice?.directorySlug).toMatch(/dr-smith-dental-clinic/);
});

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
consentSchemaValidation All required consents checked Zod passes src/__tests__/unit/consent.validation.test.ts
consentMissingRequired Terms unchecked Zod error on termsOfService src/__tests__/unit/consent.validation.test.ts

Integration Tests#

Test Setup Action Assertion File
consentLogCreation Authenticated user with Practice POST /api/consent 6 ConsentLog records created with ipAddress, timestamp, userAgent src/__tests__/integration/consent.router.test.ts
statusProgression Profile with status: TRIAL POST /api/consent ClientProfile.status updated to ONBOARDING_IN_PROGRESS src/__tests__/integration/consent.router.test.ts

E2E Tests#

Flow Steps Expected End State File
consentFlow 1. Check all required boxes 2. Leave marketing unchecked 3. Click "I Agree & Continue" 6 ConsentLog records; status: ONBOARDING_IN_PROGRESS; redirect to payment step e2e/onboarding/step-03-consent.spec.ts

Success Criteria (Binary)#

  • All 5 required consents recorded: TERMS_OF_SERVICE, PRIVACY_POLICY, GBP_MANAGEMENT, DATA_PROCESSING_AI, CITATION_NETWORK
  • ConsentLog records include practiceId, consentType, ipAddress, userAgent, timestamp
  • ClientProfile.status updated to ONBOARDING_IN_PROGRESS
  • Marketing communications consent optional and unchecked by default
  • Customer blocked from progression if any required consent is unchecked
  • Customer proceeds to payment step after all consents accepted

Agent Context (Pre-conditions)#

  • Required DB state: User + ClientProfile + Practice + Location from Steps 1–2; ConsentLog table empty
  • Required env vars: DATABASE_URL, DPDPA_COMPLIANCE_MODE=true
  • Required external mocks: None
  • Required test data: Practice with status: TRIAL, onboardingComplete: false

Verification Commands#

pnpm test:unit -- src/__tests__/unit/consent.validation.test.ts
pnpm test:integration -- src/__tests__/integration/consent.router.test.ts
pnpm test:e2e -- e2e/onboarding/step-03-consent.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-03-consent.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { createMockUserWithProfileAndLocation } from "@/__tests__/helpers/auth";

test("Step 3: Consent and terms acceptance", async ({ page }) => {
  const { profile } = await createMockUserWithProfileAndLocation();
  await page.goto("/onboarding?step=consent");

  // Check all required consents
  await page.check('[data-testid="consent-terms"]');
  await page.check('[data-testid="consent-privacy"]');
  await page.check('[data-testid="consent-gbp"]');
  await page.check('[data-testid="consent-data-processing"]');
  await page.check('[data-testid="consent-citations"]');
  // Leave marketing unchecked

  await page.click('[data-testid="agree-and-continue"]');

  // Assert redirect
  await expect(page).toHaveURL(/\/onboarding\?step=payment/, { timeout: 3000 });

  // Assert consent logs
  const consentLogs = await prisma.consentLog.findMany({
    where: { practiceId: profile.id },
  });
  expect(consentLogs).toHaveLength(5);
  expect(consentLogs.every((l) => l.ipAddress !== null && l.timestamp !== null)).toBe(true);

  // Assert status progression
  const updatedProfile = await prisma.clientProfile.findUnique({ where: { id: profile.id } });
  expect(updatedProfile?.status).toBe("ONBOARDING_IN_PROGRESS");
});

5. Step 4: Payment Method Setup#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
paymentMethodSchema Stripe token tok_visa Valid object with provider: stripe src/__tests__/unit/payment.validation.test.ts
trialSubscriptionMath Now = 2026-06-13 trial_end = 2026-06-20 src/__tests__/unit/payment.validation.test.ts

Integration Tests#

Test Setup Action Assertion File
stripeTrialSubscription Stripe mock server running POST /api/billing/subscribe Subscription record with provider: stripe, status: trialing, trialEnd = +7 days src/__tests__/integration/billing.router.test.ts
razorpayTrialSubscription Razorpay mock server running POST /api/billing/subscribe Subscription record with provider: razorpay, status: trialing src/__tests__/integration/billing.router.test.ts
webhookHandler Stripe invoice.paid event POST /api/webhooks/stripe Invoice record created; webhook responded 200 src/__tests__/integration/webhooks.test.ts

E2E Tests#

Flow Steps Expected End State File
paymentFlowStripe 1. Enter card details 2. Complete 3DS mock 3. Confirm Subscription trialing; confirmation email sent; redirect to /onboarding?step=5 e2e/onboarding/step-04-payment.spec.ts
paymentFlowRazorpay 1. Select UPI 2. Complete mock OTP 3. Confirm Subscription trialing; paymentMethodId stored in Billing table e2e/onboarding/step-04-payment.spec.ts

Success Criteria (Binary)#

  • Payment method tokenized via Stripe or Razorpay; raw card never stored locally
  • Subscription record created with status: trialing, trial_end = now + 7 days
  • Billing table stores subscriptionId, customerId, paymentMethodId
  • Webhook endpoints respond 200 OK for invoice.paid, invoice.payment_failed, customer.subscription.deleted
  • Confirmation email sent: "Your trial has started — no charge until [date]"
  • Declined card returns specific error message: "Your card was declined. Please try a different payment method."
  • 3D Secure / OTP failure allows 3 retry attempts then offers UPI alternative
  • Duplicate subscription idempotency: returns existing subscription instead of creating duplicate

Agent Context (Pre-conditions)#

  • Required DB state: User + ClientProfile + Practice + Location + ConsentLog from Steps 1–3; Subscription and Billing tables empty
  • Required env vars: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, RAZORPAY_WEBHOOK_SECRET, RESEND_API_KEY
  • Required external mocks: Stripe API mock server, Razorpay API mock server, Resend API mock server
  • Required test data: createMockStripeCustomer(), createMockRazorpayCustomer()

Verification Commands#

pnpm test:unit -- src/__tests__/unit/payment.validation.test.ts
pnpm test:integration -- src/__tests__/integration/billing.router.test.ts
pnpm test:integration -- src/__tests__/integration/webhooks.test.ts
pnpm test:e2e -- e2e/onboarding/step-04-payment.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-04-payment.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 4: Payment method setup (Stripe)", async ({ page }) => {
  const { profile } = await createMockUserWithFullProfile();
  await page.goto("/onboarding?step=payment");

  // Fill Stripe test card
  const stripeFrame = page.frameLocator('[data-testid="stripe-card-element"] iframe');
  await stripeFrame.locator('[placeholder="Card number"]').fill("4242 4242 4242 4242");
  await stripeFrame.locator('[placeholder="MM / YY"]').fill("12/30");
  await stripeFrame.locator('[placeholder="CVC"]').fill("123");

  await page.click('[data-testid="start-trial-button"]');

  // Assert redirect
  await expect(page).toHaveURL(/\/onboarding\?step=5/, { timeout: 10000 });

  // Assert DB state
  const billing = await prisma.billing.findFirst({ where: { practiceId: profile.id } });
  expect(billing).not.toBeNull();
  expect(billing?.subscriptionId).toMatch(/^sub_/);
  expect(billing?.provider).toBe("stripe");

  const subscription = await prisma.subscription.findFirst({ where: { practiceId: profile.id } });
  expect(subscription?.status).toBe("trialing");
  const trialEnd = subscription?.trialEnd;
  const sevenDaysFromNow = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  expect(trialEnd?.getDate()).toBe(sevenDaysFromNow.getDate());
});

6. Step 5: Directory Profile Creation#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
profileUrlGeneration Dr. Smith Dental Clinic, city Kochi /clinics/kochi/dr-smith-dental-clinic src/__tests__/unit/directory-profile.test.ts
slugCollision Existing dr-smith-dental-clinic in Kochi dr-smith-dental-clinic-2 src/__tests__/unit/directory-profile.test.ts
slugInvalidChars Dr. Smith & Co. (Kochi) dr-smith-and-co-kochi src/__tests__/unit/directory-profile.test.ts
citySlugMapping City Kochi kochi src/__tests__/unit/directory-profile.test.ts
directoryProfileRecord Practice with NAP DirectoryProfile created with status: DRAFT src/__tests__/unit/directory-profile.test.ts

Integration Tests#

Test Setup Action Assertion File
directoryProfileCreation Practice with no DirectoryProfile tRPC directoryProfile.create DirectoryProfile record created with slug, citySlug, status: DRAFT, profileUrl src/__tests__/integration/routers/directory-profile.test.ts
profileUrlResolution DirectoryProfile with citySlug: kochi, slug: dr-smith GET /clinics/kochi/dr-smith Returns 200 with placeholder page src/__tests__/integration/routers/directory-profile.test.ts

E2E Tests#

Flow Steps Expected End State File
profileCreationFlow Payment step completes → system auto-creates profile DirectoryProfile with status: DRAFT; profileUrl resolvable; preview placeholder visible e2e/onboarding/step-05-profile-creation.spec.ts

Success Criteria (Binary)#

  • Unique DirectoryProfile created with slug generated from businessName + URL-safe slug
  • citySlug derived from Location.city (e.g., Kochikochi)
  • profileUrl computed as /clinics/{citySlug}/{slug}
  • Slug collision handled by appending -2, -3, etc. within same city
  • Invalid characters stripped and transliterated if needed
  • DirectoryProfile.status set to DRAFT on creation
  • DirectoryProfile immediately resolvable via /clinics/{citySlug}/{slug} with placeholder page
  • ISR fallback for new profiles: dynamic route renders placeholder if no content yet
  • Profile creation failure alerts admin immediately

Agent Context (Pre-conditions)#

  • Required DB state: Practice with businessName and Location.city but no DirectoryProfile; DirectoryProfile table empty
  • Required env vars: NEXT_PUBLIC_APP_URL=https://rankflow.in
  • Required external mocks: None
  • Required test data: createMockPractice({ directoryProfile: null }), createMockDirectoryProfile()

Verification Commands#

pnpm test:unit -- src/__tests__/unit/directory-profile.test.ts
pnpm test:integration -- src/__tests__/integration/routers/directory-profile.test.ts
pnpm test:e2e -- e2e/onboarding/step-05-profile-creation.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-05-profile-creation.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 5: Directory profile creation", async ({ page }) => {
  const { profile } = await createMockUserWithFullProfile();
  // Simulate post-payment state
  await prisma.practice.update({
    where: { id: profile.id },
    data: { businessName: "Dr. Smith Dental Clinic", city: "Kochi" },
  });

  await page.goto("/onboarding?step=5");

  // Trigger directory profile creation
  await page.click('[data-testid="confirm-profile-creation"]');

  // Assert DB state
  const directoryProfile = await prisma.directoryProfile.findFirst({ where: { practiceId: profile.id } });
  expect(directoryProfile).not.toBeNull();
  expect(directoryProfile?.slug).toMatch(/^dr-smith-dental-clinic/);
  expect(directoryProfile?.citySlug).toBe("kochi");
  expect(directoryProfile?.status).toBe("DRAFT");
  expect(directoryProfile?.profileUrl).toBe("/clinics/kochi/dr-smith-dental-clinic");

  // Assert preview link
  const previewLink = page.locator('[data-testid="profile-preview-link"]');
  await expect(previewLink).toHaveAttribute("href", "https://rankflow.in/clinics/kochi/dr-smith-dental-clinic");
});

7. Step 6: Doctor Approval Gate#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
complianceCheck Bio with "guaranteed cure" checkCompliance() returns false; flagged with FORBIDDEN_WORDS hit src/__tests__/unit/medical-compliance.test.ts
complianceCheckPass Bio with "15+ years experience" checkCompliance() returns true src/__tests__/unit/medical-compliance.test.ts
approvalNotificationRender Practice + DirectoryProfile content Email HTML contains Approve/Edit/Reject buttons + preview link src/__tests__/unit/email-templates.test.ts
autoApproveTimer approvalSentAt = now - 47h DirectoryProfile.status transitions to PUBLISHED at 48h src/__tests__/unit/approval-gate.test.ts

Integration Tests#

Test Setup Action Assertion File
contentCompliance AI-generated bio, services, FAQs Call checkCompliance() on each All content passes; violations rejected and regenerated src/__tests__/integration/approval-gate.test.ts
doctorApprovalEmail Resend mock; DirectoryProfile with status: PENDING_REVIEW Inngest send-approval-email step Resend API called with preview link; EmailLog created with type: PROFILE_APPROVAL_REQUEST src/__tests__/integration/approval-gate.test.ts
approvalGateApprove Doctor clicks "Approve" in email POST /api/directory-profile/approve DirectoryProfile.statusPUBLISHED; ISR revalidation triggered; admin notified src/__tests__/integration/approval-gate.test.ts
approvalGateEdit Doctor clicks "Edit & Approve" Opens inline editor; saves changes; clicks approve DirectoryProfile content updated; statusPUBLISHED; audit log records edit src/__tests__/integration/approval-gate.test.ts
approvalGateReject Doctor clicks "Reject" DirectoryProfile.statusREJECTED; content regeneration queued; admin notified src/__tests__/integration/approval-gate.test.ts
approvalGateAutoApprove No doctor response for 48h Inngest cron or timer DirectoryProfile.statusPUBLISHED if compliance score > 90% src/__tests__/integration/approval-gate.test.ts

E2E Tests#

Flow Steps Expected End State File
approvalGateFlow AI content generated → doctor receives approval email → doctor approves DirectoryProfile.status: PUBLISHED; profile live; admin notified e2e/onboarding/step-06-approval-gate.spec.ts

Success Criteria (Binary)#

  • AI-generated content (bio, services, FAQs, reviews) passes checkCompliance() with FORBIDDEN_WORDS and FORBIDDEN_PATTERNS
  • Non-compliant content rejected and regenerated; admin alerted after 3x failure
  • Doctor receives approval email with: profile preview link, Approve/Edit+Approve/Reject buttons, 48h auto-approve notice
  • EmailLog record created with type: PROFILE_APPROVAL_REQUEST, status: DELIVERED
  • Doctor "Approve" → DirectoryProfile.statusPUBLISHED; ISR revalidation triggered; admin notified
  • Doctor "Edit & Approve" → content updated; statusPUBLISHED; audit log records edit
  • Doctor "Reject" → statusREJECTED; content regeneration queued; admin notified with feedback
  • No doctor response in 48h → auto-approve if compliance score > 90%; statusPUBLISHED
  • Unopened email triggers reminder at 24h, 36h
  • Bounced email alerts admin and attempts alternate email/SMS fallback

Agent Context (Pre-conditions)#

  • Required DB state: Practice + Location with full NAP; DirectoryProfile with status: PENDING_REVIEW and generated content; EmailLog table empty
  • Required env vars: ANTHROPIC_API_KEY, RESEND_API_KEY, RESEND_FROM_EMAIL=onboarding@rankflow.in, AUTO_APPROVE_HOURS=48
  • Required external mocks: Claude API mock (for regeneration), Resend API mock, Google OAuth mock (optional)
  • Required test data: createMockDirectoryProfile({ status: "PENDING_REVIEW" }) with generated content

Verification Commands#

pnpm test:unit -- src/__tests__/unit/medical-compliance.test.ts
pnpm test:unit -- src/__tests__/unit/email-templates.test.ts
pnpm test:unit -- src/__tests__/unit/approval-gate.test.ts
pnpm test:integration -- src/__tests__/integration/approval-gate.test.ts
pnpm test:e2e -- e2e/onboarding/step-06-approval-gate.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-06-approval-gate.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 6: Doctor approval gate", async () => {
  const { profile } = await createMockUserWithFullProfile();
  await prisma.directoryProfile.create({
    data: {
      practiceId: profile.id,
      slug: "dr-smith-dental-clinic",
      citySlug: "kochi",
      status: "PENDING_REVIEW",
      bio: "Dr. Smith leads a team of dental specialists in Kochi with 15+ years of experience.",
      services: JSON.stringify(["Teeth Cleaning", "Root Canal", "Dental Implants"]),
      faqs: JSON.stringify([{ q: "Hours?", a: "Mon-Sat 9-7" }]),
    },
  });

  // Trigger approval email
  await triggerInngestEvent("onboarding-pipeline.send-approval-email", { practiceId: profile.id });

  // Assert EmailLog
  const emailLog = await prisma.emailLog.findFirst({
    where: { practiceId: profile.id, type: "PROFILE_APPROVAL_REQUEST" },
  });
  expect(emailLog).not.toBeNull();
  expect(emailLog?.status).toBe("DELIVERED");
  expect(emailLog?.html).toContain("Approve Profile");
  expect(emailLog?.html).toContain("rankflow.in/clinics/kochi/dr-smith-dental-clinic");

  // Assert compliance
  const directoryProfile = await prisma.directoryProfile.findFirst({ where: { practiceId: profile.id } });
  expect(directoryProfile?.status).toBe("PENDING_REVIEW");
});

8. Step 7: Profile Publish#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
isrRevalidation revalidatePath("/clinics/kochi/dr-smith") Next.js ISR cache purged; revalidation tag set src/__tests__/unit/profile-publish.test.ts
schemaInjectorRender Valid JSON-LD schema <script type="application/ld+json"> in HTML src/__tests__/unit/profile-publish.test.ts

Integration Tests#

Test Setup Action Assertion File
profilePublish DirectoryProfile with status: PENDING_REVIEW or APPROVED tRPC directoryProfile.publish DirectoryProfile.statusPUBLISHED; ISR revalidation triggered; HTTP 200 at profile URL src/__tests__/integration/profile-publish.test.ts
isrRevalidation Published profile Call directoryProfile.revalidate Next.js ISR cache purged for /clinics/{citySlug}/{slug}; stale-while-revalidate enabled src/__tests__/integration/profile-publish.test.ts
profileUnpublish DirectoryProfile with status: PUBLISHED tRPC directoryProfile.unpublish DirectoryProfile.statusPAUSED; profile returns 404 or "Coming Soon" page src/__tests__/integration/profile-publish.test.ts

E2E Tests#

Flow Steps Expected End State File
publishFlow Doctor approves → admin publishes → ISR revalidates Directory profile live at /clinics/kochi/dr-smith; HTTP 200; LCP < 2.5s; schema present e2e/onboarding/step-07-profile-publish.spec.ts

Success Criteria (Binary)#

  • Directory profile live at https://rankflow.in/clinics/kochi/<slug> returning HTTP 200
  • Page LCP < 2.5 seconds on mobile and desktop
  • Schema markup present in HTML <head> and validates as JSON-LD
  • Meta tags correct: title (50–60 chars), description (150–160 chars), OG tags, canonical URL
  • Mobile-responsive rendering confirmed (viewport meta tag + CSS media queries)
  • DirectoryProfile.status set to PUBLISHED; publishedAt timestamp set
  • ISR revalidation triggered for /clinics/{citySlug}/{slug}; stale-while-revalidate enabled
  • Build failure retried once; if still failing, admin alerted and fallback "Coming Soon" page shown
  • CDN cache miss > 5s triggers Cloudflare purge and rewarm
  • Related clinics section populated with 3–5 nearby same-specialty profiles
  • Profile page includes RelatedClinics internal linking section with valid links

Agent Context (Pre-conditions)#

  • Required DB state: DirectoryProfile with status: PENDING_REVIEW or APPROVED and generated content; DirectoryProfile table ready
  • Required env vars: NEXT_PUBLIC_APP_URL, CLOUDFLARE_API_TOKEN, ISR_REVALIDATE_SECRET
  • Required external mocks: Cloudflare CDN API mock (optional), S3 mock
  • Required test data: createMockDirectoryProfile({ status: "APPROVED" }) with bio, services, FAQs

Verification Commands#

pnpm test:unit -- src/__tests__/unit/profile-publish.test.ts
pnpm test:integration -- src/__tests__/integration/profile-publish.test.ts
pnpm test:e2e -- e2e/onboarding/step-07-profile-publish.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-07-profile-publish.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 7: Profile publish", async ({ page }) => {
  const { profile } = await createMockUserWithFullProfile();
  await prisma.directoryProfile.create({
    data: {
      practiceId: profile.id,
      slug: "dr-smith-dental-clinic",
      citySlug: "kochi",
      status: "APPROVED",
      bio: "Dr. Smith leads a team of dental specialists in Kochi with 15+ years of experience.",
      services: JSON.stringify(["Teeth Cleaning", "Root Canal", "Dental Implants"]),
      faqs: JSON.stringify([{ q: "Hours?", a: "Mon-Sat 9-7" }]),
    },
  });

  // Trigger publish
  await triggerInngestEvent("onboarding-pipeline.publish-profile", { practiceId: profile.id });

  // Assert live page
  await page.goto("https://rankflow.in/clinics/kochi/dr-smith-dental-clinic");
  await expect(page).toHaveURL("https://rankflow.in/clinics/kochi/dr-smith-dental-clinic");
  expect(page.status()).resolves.toBe(200);

  // Assert schema present
  const schemaScript = page.locator('script[type="application/ld+json"]');
  await expect(schemaScript).toHaveCount(1);
  const schemaText = await schemaScript.textContent();
  expect(() => JSON.parse(schemaText!)).not.toThrow();

  // Assert related clinics section
  const relatedClinics = page.locator('[data-testid="related-clinics"]');
  await expect(relatedClinics).toBeVisible();

  // Assert DB state
  const directoryProfile = await prisma.directoryProfile.findFirst({ where: { practiceId: profile.id } });
  expect(directoryProfile?.status).toBe("PUBLISHED");
  expect(directoryProfile?.publishedAt).toBeInstanceOf(Date);
});

9. Step 8: GBP OAuth Connection Request Email#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
oauthUrlGeneration practiceId: "prf_abc123" Base64-encoded state param; correct scopes src/__tests__/unit/gbp-oauth.test.ts
emailTemplateRender Practice + OAuth URL HTML email with button, screenshots, support link src/__tests__/unit/email-templates.test.ts

Integration Tests#

Test Setup Action Assertion File
gbpOAuthEmail Resend mock; Practice with directory profile live Inngest notify-gbp-auth step Resend API called with correct to, subject, html src/__tests__/integration/gbp-oauth.test.ts
emailDeliveryTracking Resend mock returns emailId Webhook callback EmailLog record created with status: DELIVERED src/__tests__/integration/email-tracking.test.ts

E2E Tests#

Flow Steps Expected End State File
gbpOAuthEmailFlow Inngest step triggers after directory profile deploy Resend API receives email with OAuth link; EmailLog record created e2e/onboarding/step-08-gbp-oauth-email.spec.ts

Success Criteria (Binary)#

  • Email delivered via Resend API with Resend webhook confirming delivered status
  • Email subject: "Connect Your Google Business Profile — RankFlow AI"
  • Email contains Base64-encoded practiceId in OAuth URL state parameter
  • OAuth URL scopes include business.manage, userinfo.email, userinfo.profile
  • Callback URL set to /api/webhooks/google/oauth-callback
  • Email contains step-by-step visual guide, support call link, and "Need help?" section
  • EmailLog record created with type: GBP_OAUTH_REQUEST, status: DELIVERED
  • Bounced email alerts admin and attempts alternate email/SMS fallback
  • Unopened email triggers reminder #1 at 24h, #2 at 48h, #3 at 72h

Agent Context (Pre-conditions)#

  • Required DB state: Practice with status: PUBLISHED, directorySlug assigned; GbpAccount table empty; EmailLog table empty
  • Required env vars: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_OAUTH_REDIRECT_URI, RESEND_API_KEY, RESEND_FROM_EMAIL=onboarding@rankflow.in
  • Required external mocks: Resend API mock; Google OAuth mock (optional for email step)
  • Required test data: createMockPractice() with directorySlug and status: PUBLISHED

Verification Commands#

pnpm test:unit -- src/__tests__/unit/gbp-oauth.test.ts
pnpm test:unit -- src/__tests__/unit/email-templates.test.ts
pnpm test:integration -- src/__tests__/integration/gbp-oauth.test.ts
pnpm test:integration -- src/__tests__/integration/email-tracking.test.ts
pnpm test:e2e -- e2e/onboarding/step-08-gbp-oauth-email.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-08-gbp-oauth-email.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 8: GBP OAuth connection request email", async () => {
  const { profile } = await createMockUserWithFullProfile();
  await prisma.practice.update({
    where: { id: profile.id },
    data: { directorySlug: "dr-smith-dental-clinic", status: PUBLISHED },
  });

  await triggerInngestEvent("onboarding-pipeline.notify-gbp-auth", { practiceId: profile.id });

  // Assert EmailLog
  const emailLog = await prisma.emailLog.findFirst({
    where: { practiceId: profile.id, type: "GBP_OAUTH_REQUEST" },
  });
  expect(emailLog).not.toBeNull();
  expect(emailLog?.to).toBe("dr.smith@example.com");
  expect(emailLog?.status).toBe("DELIVERED");

  // Assert email content contains OAuth link
  expect(emailLog?.html).toContain("Connect Google Business Profile");
  expect(emailLog?.html).toMatch(/accounts\.google\.com\/o\/oauth2\/v2\/auth/);
  expect(emailLog?.html).toContain("business.manage");
});

10. Step 9: GBP OAuth Flow & Token Storage#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
tokenEncryption access_token: "secret123" AES-256-GCM encrypted string with IV + auth tag src/__tests__/unit/crypto.test.ts
stateParamDecode Base64 practiceId payload Decoded practiceId matches original src/__tests__/unit/gbp-oauth.test.ts
tokenExpiryCalculation expires_in: 3600 tokenExpiresAt = now + 3600s src/__tests__/unit/gbp-oauth.test.ts

Integration Tests#

Test Setup Action Assertion File
oauthCallbackHandler Google OAuth mock; valid code + state GET /api/webhooks/google/oauth-callback GbpAccount created with encrypted tokens; redirect to /dashboard/gbp?connected=true src/__tests__/integration/gbp-oauth.test.ts
accessDeniedHandler error=access_denied in callback GET /api/webhooks/google/oauth-callback Email sent with "You declined access" + fresh OAuth link src/__tests__/integration/gbp-oauth.test.ts

E2E Tests#

Flow Steps Expected End State File
gbpOAuthFlow 1. Click OAuth link from email 2. Sign in to Google 3. Click "Allow" 4. Redirect to dashboard GbpAccount with isActive: true; tokens encrypted; redirect with ?connected=true e2e/onboarding/step-09-gbp-oauth-flow.spec.ts

Success Criteria (Binary)#

  • Valid access_token + refresh_token obtained from Google OAuth2 API and encrypted with AES-256-GCM
  • GbpAccount record created with practiceId, accountEmail, encrypted tokens, tokenExpiresAt, scope: ["business.manage"], isActive: true
  • Customer redirected to /dashboard/gbp?connected=true with success toast
  • Inngest event gbp/sync-locations triggered after token storage
  • Customer clicking "Deny" receives email with explanation and fresh OAuth link
  • Customer with no GBP receives email with GBP creation guide
  • Multiple GBP accounts prompt customer to select one in dashboard
  • Token exchange failure retried 3x; then admin alerted
  • Encryption failure does NOT store unencrypted tokens; admin alerted immediately

Agent Context (Pre-conditions)#

  • Required DB state: Practice + ClientProfile; GbpAccount table empty; OAuth email sent
  • Required env vars: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_OAUTH_REDIRECT_URI, ENCRYPTION_KEY (32-byte base64)
  • Required external mocks: Google OAuth token endpoint mock, Google People API mock
  • Required test data: createMockGbpAccount() factory

Verification Commands#

pnpm test:unit -- src/__tests__/unit/crypto.test.ts
pnpm test:unit -- src/__tests__/unit/gbp-oauth.test.ts
pnpm test:integration -- src/__tests__/integration/gbp-oauth.test.ts
pnpm test:e2e -- e2e/onboarding/step-09-gbp-oauth-flow.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-09-gbp-oauth-flow.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 9: GBP OAuth flow and token storage", async ({ page }) => {
  const { profile } = await createMockUserWithFullProfile();
  await prisma.practice.update({
    where: { id: profile.id },
    data: { directorySlug: "dr-smith-dental-clinic", status: PUBLISHED },
  });

  // Simulate OAuth callback
  const state = Buffer.from(JSON.stringify({ practiceId: profile.id })).toString("base64");
  await page.goto(`/api/webhooks/google/oauth-callback?code=mock_auth_code&state=${state}`);

  // Assert redirect to dashboard
  await expect(page).toHaveURL("/dashboard/gbp?connected=true", { timeout: 5000 });

  // Assert GbpAccount created
  const gbpAccount = await prisma.gbpAccount.findFirst({ where: { practiceId: profile.id } });
  expect(gbpAccount).not.toBeNull();
  expect(gbpAccount?.isActive).toBe(true);
  expect(gbpAccount?.scope).toContain("business.manage");
  expect(gbpAccount?.accessToken).not.toBe("mock_access_token_12345"); // encrypted
  expect(gbpAccount?.refreshToken).not.toBe("mock_refresh_token_67890"); // encrypted
  expect(gbpAccount?.tokenExpiresAt).toBeInstanceOf(Date);
});

11. Step 10: GBP Location Sync#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
locationMapping GBP API location + local Location GbpLocation linked to Location via practiceId src/__tests__/unit/gbp-sync.test.ts
categoryMapping category: DENTIST GBP primaryCategory.displayName: "Dentist" src/__tests__/unit/gbp-sync.test.ts

Integration Tests#

Test Setup Action Assertion File
gbpLocationSync GbpAccount with valid tokens; GBP API mock with 1 location Inngest gbp/sync-locations GbpLocation record created with gbpLocationId, mapped to Location src/__tests__/integration/gbp-sync.test.ts
businessInfoSync Practice with updated hours Inngest gbp/sync-locations GBP API patch called with updated description, hours, phone, website src/__tests__/integration/gbp-sync.test.ts

E2E Tests#

Flow Steps Expected End State File
gbpSyncFlow OAuth complete → Inngest triggers sync GbpLocation records synced; dashboard shows "Connected — X locations" e2e/onboarding/step-10-gbp-sync.spec.ts

Success Criteria (Binary)#

  • All GBP locations synced to local GbpLocation records with gbpLocationId, name, address, phone, hours, category
  • GbpLocation records linked to Location records via practiceId
  • Business info updated on GBP API: description, hours, category, phone, directory profile URL
  • Logo and photos uploaded to GBP if provided during onboarding
  • Customer sees GBP status as "Connected — X locations" in /dashboard/gbp
  • GBP API rate limit (HTTP 429) triggers exponential backoff retry (5min, 15min, 1h)
  • Location data mismatch (address/phone differs) flags for admin review and emails customer
  • Photo upload failure skips photos and queues for manual upload
  • Category not supported falls back to closest supported category and flags for manual correction

Agent Context (Pre-conditions)#

  • Required DB state: GbpAccount with encrypted tokens; Location with full NAP; GbpLocation table empty
  • Required env vars: GOOGLE_API_KEY, GBP_API_RATE_LIMIT=100/min
  • Required external mocks: GBP Business Information API mock (accounts.locations.list, locations.patch), GBP Media API mock
  • Required test data: createMockGbpLocation(), createMockLocation()

Verification Commands#

pnpm test:unit -- src/__tests__/unit/gbp-sync.test.ts
pnpm test:integration -- src/__tests__/integration/gbp-sync.test.ts
pnpm test:e2e -- e2e/onboarding/step-10-gbp-sync.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-10-gbp-sync.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 10: GBP location sync", async () => {
  const { profile } = await createMockUserWithFullProfile();
  await prisma.gbpAccount.create({
    data: {
      practiceId: profile.id,
      accountEmail: "dr.smith@example.com",
      accessToken: "encrypted_access_token",
      refreshToken: "encrypted_refresh_token",
      tokenExpiresAt: new Date(Date.now() + 3600 * 1000),
      scope: ["business.manage"],
      isActive: true,
    },
  });

  await triggerInngestEvent("gbp/sync-locations", { practiceId: profile.id });

  // Assert GbpLocation records
  const gbpLocations = await prisma.gbpLocation.findMany({ where: { practiceId: profile.id } });
  expect(gbpLocations.length).toBeGreaterThanOrEqual(1);
  expect(gbpLocations[0]?.gbpLocationId).toMatch(/^locations\//);

  // Assert linked to Location
  const location = await prisma.location.findFirst({ where: { practiceId: profile.id } });
  expect(gbpLocations[0]?.practiceId).toBe(location?.practiceId);
});

12. Step 11: Social Media Connection Prompts#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
composioUrlGeneration platform: instagram, practiceId: prf_abc OAuth URL with practiceId in state param src/__tests__/unit/social-oauth.test.ts
platformSelectionLogic category: DENTIST Instagram + Facebook prioritized; LinkedIn + Twitter optional src/__tests__/unit/social-oauth.test.ts

Integration Tests#

Test Setup Action Assertion File
socialConnectEmail Resend mock; Practice with GBP synced Inngest social connection step Resend API called with platform buttons; EmailLog created src/__tests__/integration/social-connect.test.ts
composioOAuthCallback Composio mock returns connectionId GET /api/webhooks/composio/oauth-callback SocialAccount created with composioConnectionId, isActive: true src/__tests__/integration/social-connect.test.ts

E2E Tests#

Flow Steps Expected End State File
socialConnectFlow 1. Receive email 2. Click Instagram button 3. Authorize via Composio 4. Redirect to dashboard SocialAccount with platform: INSTAGRAM, isActive: true; dashboard shows connected e2e/onboarding/step-11-social-connect.spec.ts

Success Criteria (Binary)#

  • At least 1 social platform connected (Instagram or Facebook preferred for medical vertical)
  • SocialAccount record created with platform, accountName, composioConnectionId, isActive: true
  • Customer sees connected accounts in /dashboard/social
  • Email contains platform-specific connection buttons with practiceId in state param
  • No social accounts connected after 48h → onboarding proceeds; social prompts resent at 48h, 96h
  • Composio OAuth failure displays specific error: "Please ensure you are an admin of your Facebook Business Page"
  • Meta Business Account missing → email guide sent: "How to create a Meta Business Account in 3 minutes"
  • Platform not available in India → hidden from prompt; customer notified when available
  • Token storage failure retried 3x; admin alerted; connection_id never lost

Agent Context (Pre-conditions)#

  • Required DB state: Practice with GBP synced; SocialAccount table empty; EmailLog table empty
  • Required env vars: COMPOSIO_API_KEY, COMPOSIO_CLIENT_ID, ZERNIO_API_KEY
  • Required external mocks: Composio OAuth mock, Zernio API mock, Resend API mock
  • Required test data: createMockSocialAccount() factory

Verification Commands#

pnpm test:unit -- src/__tests__/unit/social-oauth.test.ts
pnpm test:integration -- src/__tests__/integration/social-connect.test.ts
pnpm test:e2e -- e2e/onboarding/step-11-social-connect.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-11-social-connect.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 11: Social media connection prompts", async ({ page }) => {
  const { profile } = await createMockUserWithFullProfile();
  await prisma.gbpAccount.create({
    data: {
      practiceId: profile.id,
      accountEmail: "dr.smith@example.com",
      accessToken: "encrypted",
      refreshToken: "encrypted",
      tokenExpiresAt: new Date(Date.now() + 3600 * 1000),
      scope: ["business.manage"],
      isActive: true,
    },
  });

  await page.goto("/dashboard/social");

  // Click Instagram connect
  await page.click('[data-testid="connect-instagram"]');

  // Simulate Composio OAuth callback
  const state = Buffer.from(JSON.stringify({ practiceId: profile.id, platform: "INSTAGRAM" })).toString("base64");
  await page.goto(`/api/webhooks/composio/oauth-callback?connectionId=conn_mock_instagram&state=${state}`);

  // Assert redirect
  await expect(page).toHaveURL("/dashboard/social?connected=instagram", { timeout: 5000 });

  // Assert SocialAccount
  const socialAccount = await prisma.socialAccount.findFirst({
    where: { practiceId: profile.id, platform: "INSTAGRAM" },
  });
  expect(socialAccount).not.toBeNull();
  expect(socialAccount?.isActive).toBe(true);
  expect(socialAccount?.composioConnectionId).toBe("conn_mock_instagram");
});

13. Step 12: Citation Directory Description Generation#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
descriptionUniqueness 30 generated descriptions > 80% uniqueness score; no duplicates > 70% similarity src/__tests__/unit/citation-description.test.ts
lengthValidation Description: 50 chars Rejected (min 100 chars); regenerated src/__tests__/unit/citation-description.test.ts
medicalComplianceFilter Description with "guaranteed cure" Rejected; flagged; regenerated src/__tests__/unit/citation-description.test.ts

Integration Tests#

Test Setup Action Assertion File
descriptionGeneration 30 CitationDirectory seeded; Practice with NAP Inngest citation-builder step 3 30 description objects in memory, each with directoryId, title, description, keywords src/__tests__/integration/citation-builder.test.ts
llmRouterBulk task: "citation_description", 30 directories Call ai.generate() batch Llama 3 70B selected; cost < Rs 5; all descriptions returned in < 45s src/__tests__/integration/ai-router.test.ts

E2E Tests#

Flow Steps Expected End State File
citationDescriptionFlow Inngest triggers after directory profile deploy 30 unique descriptions in Citation records or workflow memory e2e/onboarding/step-12-citation-descriptions.spec.ts

Success Criteria (Binary)#

  • 30 unique descriptions generated with > 80% uniqueness score
  • Each description 100–500 characters (directory-specific length)
  • Keywords naturally included; no keyword stuffing detected
  • Medical compliance verified: no prohibited claims (guaranteed cure, 100% success, drug names)
  • Generation cost < Rs 5 total (Llama 3 bulk pricing)
  • LLM timeout > 15s per description retries with Claude Haiku fallback
  • Duplicate descriptions (> 70% similarity) regenerated with stricter uniqueness prompt
  • Description too long/short truncated or expanded via secondary LLM call

Agent Context (Pre-conditions)#

  • Required DB state: 30 CitationDirectory records seeded; Practice + Location with full NAP; Citation table empty
  • Required env vars: TOGETHER_API_KEY, ANTHROPIC_API_KEY, LLM_ROUTER_TIMEOUT=15000
  • Required external mocks: Together.xyz Llama API mock, Claude Haiku fallback mock
  • Required test data: MOCK_CITATION_DIRECTORIES (30 directories), createMockNAP()

Verification Commands#

pnpm test:unit -- src/__tests__/unit/citation-description.test.ts
pnpm test:integration -- src/__tests__/integration/citation-builder.test.ts
pnpm test:integration -- src/__tests__/integration/ai-router.test.ts
pnpm test:e2e -- e2e/onboarding/step-12-citation-descriptions.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-12-citation-descriptions.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";
import { seedCitationDirectories } from "@/__tests__/seeders/citation-directories";

test("Step 12: Citation description generation", async () => {
  const { profile } = await createMockUserWithFullProfile();
  await seedCitationDirectories();

  await triggerInngestEvent("citation-builder.generate-descriptions", { practiceId: profile.id });

  // Assert descriptions generated in Citation records or workflow state
  const citations = await prisma.citation.findMany({ where: { practiceId: profile.id } });
  expect(citations.length).toBe(30);

  // Uniqueness check
  const descriptions = citations.map((c) => c.description);
  const uniqueDescriptions = new Set(descriptions);
  expect(uniqueDescriptions.size / descriptions.length).toBeGreaterThanOrEqual(0.8);

  // Compliance check
  const hasBanned = descriptions.some((d) => /guaranteed cure|100% success|miracle/i.test(d));
  expect(hasBanned).toBe(false);

  // Length check
  const allValidLength = descriptions.every((d) => d.length >= 100 && d.length <= 500);
  expect(allValidLength).toBe(true);
});

14. Step 13: Citation Submission to 30 Directories#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
apiSubmitter directory: practo, valid NAP HTTP POST to Practo API; success pattern matched src/__tests__/unit/citation-submitter.test.ts
formSubmitter directory: justdial, valid NAP Puppeteer form fill; screenshot captured; confirmation detected src/__tests__/unit/citation-submitter.test.ts
ownedBlogSubmitter directory: kerala-health Direct DB insert; ISR re-render triggered src/__tests__/unit/citation-submitter.test.ts
idempotencyCheck Duplicate submission attempt Skipped; existing Citation record returned src/__tests__/unit/citation-submitter.test.ts

Integration Tests#

Test Setup Action Assertion File
batchSubmission 30 descriptions + NAP + 30 directories Inngest citation-builder step 4 ≥ 24 Citation records with status: SUBMITTED; all have directoryUrl or screenshotUrl src/__tests__/integration/citation-builder.test.ts
concurrencyLimit 30 directories queued Submission engine runs Max 3 concurrent submissions; no rate limit breaches src/__tests__/integration/citation-builder.test.ts

E2E Tests#

Flow Steps Expected End State File
citationSubmissionFlow Inngest triggers submission after description generation ≥ 24 Citation SUBMITTED; screenshots stored; NAP consistent e2e/onboarding/step-13-citation-submit.spec.ts

Success Criteria (Binary)#

  • ≥ 80% of 30 directories successfully submitted (24+ live citations)
  • All submitted citations have directoryUrl or screenshotUrl as proof
  • NAP data consistent across all submissions (name, address, phone match onboarding form)
  • No duplicate submissions (idempotency check on practiceId + directoryName)
  • CAPTCHA blocking triggers 2captcha service → retry once → manual flag
  • Phone verification required uses virtual number service or flags for manual
  • Directory API down (HTTP 5xx) retries 3x with 5min backoff → queues for monthly refresh
  • Form structure changed (selector not found) alerts admin and updates submission schema
  • Duplicate listing exists → updates existing listing instead and stores URL
  • Owned blog ISR failure retries build; if failing, static HTML fallback deployed

Agent Context (Pre-conditions)#

  • Required DB state: 30 CitationDirectory records; 30 descriptions in Citation or workflow memory; Practice + Location with NAP
  • Required env vars: HYPERBROWSER_API_KEY, S3_CITATIONS_BUCKET, CAPTCHA_SOLVER_KEY
  • Required external mocks: Practo API mock, Justdial form mock (Hyperbrowser), S3 mock
  • Required test data: MOCK_CITATION_DIRECTORIES, createMockCitation()

Verification Commands#

pnpm test:unit -- src/__tests__/unit/citation-submitter.test.ts
pnpm test:integration -- src/__tests__/integration/citation-builder.test.ts
pnpm test:e2e -- e2e/onboarding/step-13-citation-submit.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-13-citation-submit.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";
import { seedCitationDirectories } from "@/__tests__/seeders/citation-directories";

test("Step 13: Citation submission to 30 directories", async () => {
  const { profile } = await createMockUserWithFullProfile();
  await seedCitationDirectories();

  // Pre-seed citations with descriptions
  const directories = await prisma.citationDirectory.findMany();
  await prisma.citation.createMany({
    data: directories.map((dir, i) => ({
      practiceId: profile.id,
      locationId: profile.id, // simplified
      directoryName: dir.name,
      status: "PENDING",
      description: `Unique description for ${dir.name} — Dr. Smith Dental Clinic in Kochi. ${i}`,
      submittedAt: null,
    })),
  });

  await triggerInngestEvent("citation-builder.submit", { practiceId: profile.id });

  // Assert submission results
  const citations = await prisma.citation.findMany({ where: { practiceId: profile.id } });
  const submitted = citations.filter((c) => c.status === "SUBMITTED");
  expect(submitted.length).toBeGreaterThanOrEqual(24); // 80% success rate

  // Assert proof
  const withProof = submitted.filter((c) => c.directoryUrl || c.screenshotUrl);
  expect(withProof.length).toBe(submitted.length);

  // Assert NAP consistency
  const napSnapshot = submitted[0]?.napSnapshot;
  expect(napSnapshot).toBeDefined();
  expect(JSON.parse(napSnapshot as string).name).toBe("Dr. Smith Dental Clinic");
});

15. Step 14: NAP Verification Scheduling#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
sleepDuration step.sleep("7d") sleepUntil = now + 7 days src/__tests__/unit/inngest-schedule.test.ts
cronExpression 0 2 1 * * Next run = 1st of next month at 2 AM IST src/__tests__/unit/inngest-schedule.test.ts

Integration Tests#

Test Setup Action Assertion File
napVerifySchedule Citations with status: SUBMITTED Inngest citation-builder step 6 NapCheckProcessor BullMQ job scheduled with 7-day delay; recurring cron set src/__tests__/integration/citation-builder.test.ts

E2E Tests#

Flow Steps Expected End State File
napScheduleFlow All submissions complete → Inngest sleeps Citation records have status: SUBMITTED and submittedAt populated; verification event scheduled e2e/onboarding/step-14-nap-verify-schedule.spec.ts

Success Criteria (Binary)#

  • Verification event scheduled successfully after all submissions complete
  • Citation records have status: SUBMITTED and submittedAt populated
  • Inngest step.sleep("7d") schedules skill/14-citation-verify-nap event for +7 days
  • Recurring backup job scheduled: 0 2 1 * * (1st of month at 2 AM IST) in BullMQ
  • Inngest sleep failure falls back to BullMQ delayed job with 7-day delay
  • Event not sent after sleep triggers admin alert and manual verification trigger

Agent Context (Pre-conditions)#

  • Required DB state: ≥ 24 Citation records with status: SUBMITTED; NapCheck queue configured
  • Required env vars: INNGEST_EVENT_KEY, BULLMQ_REDIS_URL, NAP_VERIFY_DELAY_DAYS=7
  • Required external mocks: None (internal scheduling)
  • Required test data: createMockCitation({ status: "SUBMITTED", submittedAt: new Date() })

Verification Commands#

pnpm test:unit -- src/__tests__/unit/inngest-schedule.test.ts
pnpm test:integration -- src/__tests__/integration/citation-builder.test.ts
pnpm test:e2e -- e2e/onboarding/step-14-nap-verify-schedule.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-14-nap-verify-schedule.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 14: NAP verification scheduling", async () => {
  const { profile } = await createMockUserWithFullProfile();
  // Seed submitted citations
  await prisma.citation.createMany({
    data: Array.from({ length: 30 }, (_, i) => ({
      practiceId: profile.id,
      locationId: profile.id,
      directoryName: `directory-${i}`,
      status: "SUBMITTED",
      submittedAt: new Date(),
      description: `Description ${i}`,
    })),
  });

  await triggerInngestEvent("citation-builder.schedule-verify", { practiceId: profile.id });

  // Assert all citations have submittedAt
  const citations = await prisma.citation.findMany({ where: { practiceId: profile.id } });
  expect(citations.every((c) => c.status === "SUBMITTED" && c.submittedAt !== null)).toBe(true);

  // Assert Inngest scheduled event (via Inngest test helper)
  const scheduledEvents = await getInngestScheduledEvents("skill/14-citation-verify-nap");
  expect(scheduledEvents.length).toBeGreaterThanOrEqual(1);
  expect(scheduledEvents[0]?.payload.practiceId).toBe(profile.id);
});

16. Step 15: First GBP Post Generation & Approval Queue#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
gbpPostLength Content: 1600 chars Truncated to 1500 chars; flag for review src/__tests__/unit/gbp-post.test.ts
medicalComplianceGbp Post with "guaranteed results" Rejected; regenerated; alert if 2x failure src/__tests__/unit/gbp-post.test.ts
autoPublishTimer autoPublishAt = now + 24h ContentPiece.status transitions to PUBLISHED at expiry src/__tests__/unit/gbp-post.test.ts

Integration Tests#

Test Setup Action Assertion File
firstGbpPost GbpAccount connected; Practice with NAP Inngest create-first-post step ContentPiece created with type: GBP_POST, status: PENDING_REVIEW, 24h timer src/__tests__/integration/gbp-post.test.ts
approvalQueue ContentPiece in PENDING_REVIEW Customer approves via dashboard ContentPiece.statusAPPROVED; GbpPost queued for publish src/__tests__/integration/gbp-post.test.ts

E2E Tests#

Flow Steps Expected End State File
firstGbpPostFlow GBP OAuth complete → Inngest generates post Post in /dashboard/content with 24h countdown; email sent; passes compliance e2e/onboarding/step-15-first-gbp-post.spec.ts

Success Criteria (Binary)#

  • First GBP post generated within 30 seconds of OAuth completion
  • Content 150–300 words with CTA included ("Book appointment", "Call now")
  • Content passes medical compliance filter (no prohibited claims)
  • ContentPiece stored with type: GBP_POST, status: PENDING_REVIEW, autoPublishAt = now + 24h
  • Customer notified via email: "Your first GBP post is ready for review"
  • Post visible in /dashboard/content with 24-hour countdown timer
  • Customer can approve, reject, or edit before auto-publish
  • Non-compliant content rejected and regenerated; admin alerted after 2x failure
  • Post > 1500 chars truncated to 1500 and flagged for review
  • Customer rejects post → archived; regenerated with different angle; re-queued
  • Customer doesn't review in 24h → auto-published if compliance score > 90%

Agent Context (Pre-conditions)#

  • Required DB state: GbpAccount with isActive: true; Practice with NAP; ContentPiece table empty
  • Required env vars: ANTHROPIC_API_KEY, GBP_POST_MAX_LENGTH=1500, AUTO_PUBLISH_HOURS=24
  • Required external mocks: Claude Haiku mock for GBP post generation
  • Required test data: createMockContentPiece(), createMockGbpPost()

Verification Commands#

pnpm test:unit -- src/__tests__/unit/gbp-post.test.ts
pnpm test:integration -- src/__tests__/integration/gbp-post.test.ts
pnpm test:e2e -- e2e/onboarding/step-15-first-gbp-post.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-15-first-gbp-post.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 15: First GBP post generation and approval queue", async () => {
  const { profile } = await createMockUserWithFullProfile();
  await prisma.gbpAccount.create({
    data: {
      practiceId: profile.id,
      accountEmail: "dr.smith@example.com",
      accessToken: "encrypted",
      refreshToken: "encrypted",
      tokenExpiresAt: new Date(Date.now() + 3600 * 1000),
      scope: ["business.manage"],
      isActive: true,
    },
  });

  await triggerInngestEvent("onboarding-pipeline.create-first-post", { practiceId: profile.id });

  // Assert ContentPiece created
  const contentPiece = await prisma.contentPiece.findFirst({
    where: { practiceId: profile.id, type: "GBP_POST" },
  });
  expect(contentPiece).not.toBeNull();
  expect(contentPiece?.status).toBe("PENDING_REVIEW");
  expect(contentPiece?.content.length).toBeLessThanOrEqual(1500);
  expect(contentPiece?.autoPublishAt).toBeInstanceOf(Date);

  // Assert compliance
  expect(/guaranteed|miracle|100% success/i.test(contentPiece?.content || "")).toBe(false);

  // Assert email sent
  const emailLog = await prisma.emailLog.findFirst({
    where: { practiceId: profile.id, type: "CONTENT_APPROVAL_REMINDER" },
  });
  expect(emailLog).not.toBeNull();
});

17. Step 16: First Social Posts Generation & Approval Queue#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
instagramCaptionLength Caption: 200 words Hashtag set (10–15 tags); character count < 2200 src/__tests__/unit/social-post.test.ts
facebookPostFormat Longer post (150–200 words) Contains directory profile link; CTA button src/__tests__/unit/social-post.test.ts
platformCharacterLimit Twitter post: 300 chars Truncated to 280 chars src/__tests__/unit/social-post.test.ts

Integration Tests#

Test Setup Action Assertion File
firstSocialPosts SocialAccount for Instagram + Facebook connected AI Router generates posts 2 ContentPiece records: type: SOCIAL_POST, status: PENDING_REVIEW src/__tests__/integration/social-post.test.ts
crossPlatformAdaptation Same core topic Generate for 2 platforms Instagram has hashtags; Facebook has link; content differs per platform src/__tests__/integration/social-post.test.ts

E2E Tests#

Flow Steps Expected End State File
firstSocialPostsFlow Social connected → Inngest generates posts 1 post per connected platform in approval queue; platform-appropriate format e2e/onboarding/step-16-first-social-posts.spec.ts

Success Criteria (Binary)#

  • 1 post per connected platform generated (Instagram, Facebook, LinkedIn, Twitter)
  • Content platform-appropriate: hashtags for Instagram, links for Facebook, professional for LinkedIn, short for Twitter
  • All posts in approval queue at /dashboard/content with status: PENDING_REVIEW and 24h timer
  • Image prompt generated (optional DALL-E/Stable Diffusion for hero image)
  • No social accounts connected → this step skipped; onboarding proceeds
  • Platform-specific content violation (length/format) triggers per-platform regeneration
  • Image generation failure → post text-only; image queued for manual creation

Agent Context (Pre-conditions)#

  • Required DB state: SocialAccount records with isActive: true for ≥ 1 platform; ContentPiece table empty
  • Required env vars: OPENAI_API_KEY, DALLE_API_KEY (optional), SOCIAL_POST_MAX_LENGTH_IG=2200, SOCIAL_POST_MAX_LENGTH_TWITTER=280
  • Required external mocks: GPT-4o-mini mock for social captions; DALL-E mock (optional)
  • Required test data: createMockSocialPost(), createMockContentPiece()

Verification Commands#

pnpm test:unit -- src/__tests__/unit/social-post.test.ts
pnpm test:integration -- src/__tests__/integration/social-post.test.ts
pnpm test:e2e -- e2e/onboarding/step-16-first-social-posts.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-16-first-social-posts.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 16: First social posts generation and approval queue", async () => {
  const { profile } = await createMockUserWithFullProfile();
  await prisma.socialAccount.createMany({
    data: [
      { practiceId: profile.id, platform: "INSTAGRAM", composioConnectionId: "conn_instagram", isActive: true, accountName: "drsmithdental" },
      { practiceId: profile.id, platform: "FACEBOOK", composioConnectionId: "conn_facebook", isActive: true, accountName: "Dr. Smith Dental Clinic" },
    ],
  });

  await triggerInngestEvent("onboarding-pipeline.create-social-posts", { practiceId: profile.id });

  // Assert ContentPieces created
  const socialPosts = await prisma.contentPiece.findMany({
    where: { practiceId: profile.id, type: "SOCIAL_POST" },
  });
  expect(socialPosts.length).toBe(2);

  const igPost = socialPosts.find((p) => p.platform === "INSTAGRAM");
  const fbPost = socialPosts.find((p) => p.platform === "FACEBOOK");

  expect(igPost).toBeDefined();
  expect(fbPost).toBeDefined();
  expect(igPost?.status).toBe("PENDING_REVIEW");
  expect(fbPost?.status).toBe("PENDING_REVIEW");

  // Assert platform appropriateness
  expect(igPost?.content).toMatch(/#[A-Za-z]+/); // hashtags
  expect(fbPost?.content).toMatch(/https?:\/\//); // link
});

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
emailTemplateValidation Practice with directory profile, citations, GBP, social HTML contains all 6 sections with correct links src/__tests__/unit/email-templates.test.ts
urlValidation directorySlug: dr-smith-dental-clinic All deep links return 200 before email send src/__tests__/unit/email-templates.test.ts

Integration Tests#

Test Setup Action Assertion File
welcomeEmailSend Resend mock; onboarding data complete Inngest final step → Resend API EmailLog with type: WELCOME, status: DELIVERED; all links UTM-tagged src/__tests__/integration/email-delivery.test.ts
emailOpenTracking Resend webhook opened event POST /api/webhooks/resend EmailLog.openedAt populated src/__tests__/integration/email-tracking.test.ts

E2E Tests#

Flow Steps Expected End State File
welcomeEmailFlow Onboarding completion triggers email Email delivered within 5 min; all deep links functional and UTM-tagged; open tracked e2e/onboarding/step-17-welcome-email.spec.ts

Success Criteria (Binary)#

  • Email delivered within 5 minutes of onboarding completion (Resend webhook confirms delivered)
  • Email subject: "🎉 Your RankFlow AI Dashboard is Ready — Here's Everything That's Live"
  • Email contains 6 sections: directory profile link, citation dashboard link, GBP connect link (if pending), content review link, dashboard CTA, support link
  • All deep links functional and UTM-tagged (utm_source=welcome_email, utm_campaign=onboarding)
  • Email opens tracked via Resend webhook (EmailLog.openedAt populated)
  • Bounced email alerts admin; retries with alternate email; SMS fallback if phone available
  • Dashboard link broken detected by pre-validation; email blocked until links fixed

Agent Context (Pre-conditions)#

  • Required DB state: Practice with status: PUBLISHED; Citation records; GbpAccount (connected or not); SocialAccount (connected or not); EmailLog table ready
  • Required env vars: RESEND_API_KEY, RESEND_FROM_EMAIL=onboarding@rankflow.in, NEXT_PUBLIC_APP_URL
  • Required external mocks: Resend API mock; Resend webhook mock
  • Required test data: createMockEmailLog()

Verification Commands#

pnpm test:unit -- src/__tests__/unit/email-templates.test.ts
pnpm test:integration -- src/__tests__/integration/email-delivery.test.ts
pnpm test:integration -- src/__tests__/integration/email-tracking.test.ts
pnpm test:e2e -- e2e/onboarding/step-17-welcome-email.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-17-welcome-email.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 17: Welcome email with dashboard link", async () => {
  const { profile } = await createMockUserWithFullProfile();
  await prisma.practice.update({
    where: { id: profile.id },
    data: { directorySlug: "dr-smith-dental-clinic", status: PUBLISHED },
  });
  await prisma.citation.createMany({
    data: Array.from({ length: 24 }, (_, i) => ({
      practiceId: profile.id,
      locationId: profile.id,
      directoryName: `dir-${i}`,
      status: "SUBMITTED",
      submittedAt: new Date(),
      description: `Desc ${i}`,
    })),
  });

  await triggerInngestEvent("onboarding-pipeline.send-welcome-email", { practiceId: profile.id });

  const emailLog = await prisma.emailLog.findFirst({
    where: { practiceId: profile.id, type: "WELCOME" },
  });
  expect(emailLog).not.toBeNull();
  expect(emailLog?.status).toBe("DELIVERED");
  expect(emailLog?.html).toContain("Your RankFlow AI Dashboard is Ready");
  expect(emailLog?.html).toContain("rankflow.in/clinics/kochi/dr-smith-dental-clinic");
  expect(emailLog?.html).toContain("/dashboard/citations");
  expect(emailLog?.html).toContain("/dashboard/content");
  expect(emailLog?.html).toContain("utm_source=welcome_email");
});

19. Step 18: Welcome Report Generation#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
reportDataGathering practiceId with all onboarding data All 7 sections populated; no null crashes src/__tests__/unit/report-generator.test.ts
pdfGeneration HTML report template Playwright renders A4 PDF; < 30s src/__tests__/unit/report-generator.test.ts
s3Upload PDF buffer URL returned; reports/welcome-{practiceId}-{date}.pdf src/__tests__/unit/report-generator.test.ts

Integration Tests#

Test Setup Action Assertion File
welcomeReport Onboarding data complete; Playwright + S3 mocks Inngest send-welcome-report Report record created; PDF uploaded to S3; attached to email src/__tests__/integration/reporting.test.ts
incompleteDataFallback Some Citation records null Report generation "Pending" displayed for incomplete sections; no crash src/__tests__/integration/reporting.test.ts

E2E Tests#

Flow Steps Expected End State File
welcomeReportFlow Inngest triggers report generation PDF in S3; attached to welcome email; accurate status data e2e/onboarding/step-18-welcome-report.spec.ts

Success Criteria (Binary)#

  • PDF generated and uploaded to S3 within 45 seconds
  • Report record created with type: WELCOME, pdfUrl pointing to S3
  • PDF attached to welcome email (multipart MIME with attachment)
  • Report contains accurate, up-to-date onboarding status: Executive Summary, Citation Status (30 directories), Directory Profile Preview, GBP Connection Status, Social Connection Status, What's Next timeline, Quick Links
  • Incomplete data displays "Pending" instead of crashing
  • PDF generation timeout > 30s retries once; if still failing, sends HTML email instead
  • S3 upload failure retries 3x; queues for later delivery
  • Report data incomplete (null values) → "Pending" sections; does not crash

Agent Context (Pre-conditions)#

  • Required DB state: All onboarding data from Steps 1–17; Report table empty; S3 bucket configured
  • Required env vars: S3_REPORTS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, PLAYWRIGHT_PDF_TIMEOUT=30000
  • Required external mocks: S3 mock (MinIO or localstack); Playwright PDF generation mock
  • Required test data: createMockReport()

Verification Commands#

pnpm test:unit -- src/__tests__/unit/report-generator.test.ts
pnpm test:integration -- src/__tests__/integration/reporting.test.ts
pnpm test:e2e -- e2e/onboarding/step-18-welcome-report.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-18-welcome-report.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 18: Welcome report generation", async () => {
  const { profile } = await createMockUserWithFullProfile();
  // Seed complete onboarding data
  await prisma.practice.update({
    where: { id: profile.id },
    data: { directorySlug: "dr-smith-dental-clinic", status: PUBLISHED },
  });
  await prisma.citation.createMany({
    data: Array.from({ length: 24 }, (_, i) => ({
      practiceId: profile.id,
      locationId: profile.id,
      directoryName: `dir-${i}`,
      status: "SUBMITTED",
      submittedAt: new Date(),
      description: `Desc ${i}`,
    })),
  });
  await prisma.gbpAccount.create({
    data: {
      practiceId: profile.id,
      accountEmail: "dr.smith@example.com",
      accessToken: "encrypted",
      refreshToken: "encrypted",
      tokenExpiresAt: new Date(Date.now() + 3600 * 1000),
      scope: ["business.manage"],
      isActive: true,
    },
  });

  await triggerInngestEvent("onboarding-pipeline.send-welcome-report", { practiceId: profile.id });

  const report = await prisma.report.findFirst({
    where: { practiceId: profile.id, type: "WELCOME" },
  });
  expect(report).not.toBeNull();
  expect(report?.pdfUrl).toMatch(/reports\/welcome-/);
  expect(report?.pdfUrl).toContain(profile.id);
});

20. Step 19: Onboarding Completion Notification#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
statusUpdate onboardingComplete: falsetrue ClientProfile.statusACTIVE; onboardedAt set src/__tests__/unit/onboarding-completion.test.ts
cronExpressionValidation GBP posts: 0 9 * * 1,3,5 Cron parses correctly; next run is Mon/Wed/Fri 9 AM IST src/__tests__/unit/onboarding-completion.test.ts
jobIdempotency Same practiceId scheduled twice Second schedule detects duplicate; no duplicate jobs src/__tests__/unit/onboarding-completion.test.ts

Integration Tests#

Test Setup Action Assertion File
onboardingComplete Inngest workflow returns { practice_id, status: "onboarded" } Prisma update + BullMQ scheduling ClientProfile.onboardingComplete: true; 6 recurring jobs in BullMQ src/__tests__/integration/onboarding-completion.test.ts
recurringJobSchedule ClientProfile with status: ACTIVE BullMQ job creation Jobs: gbp-post, social-post, review-monitor, nap-check, monthly-report, profile-refresh src/__tests__/integration/onboarding-completion.test.ts

E2E Tests#

Flow Steps Expected End State File
onboardingCompleteFlow All steps complete → Inngest finalizes onboardingComplete: true; jobs scheduled; completion email sent; dashboard functional e2e/onboarding/step-19-onboarding-complete.spec.ts

Success Criteria (Binary)#

  • ClientProfile.onboardingComplete set to true
  • ClientProfile.status updated to ACTIVE
  • ClientProfile.onboardedAt set to current timestamp
  • 6 recurring jobs scheduled in BullMQ with correct cron expressions:
    • GBP posts: 0 9 * * 1,3,5 (Mon/Wed/Fri 9 AM IST)
    • Social posts: 0 10 * * 2,4 (Tue/Thu 10 AM IST)
    • Review monitor: 0 8 * * * (Daily 8 AM IST)
    • NAP check: 0 2 1 * * (1st of month 2 AM IST)
    • Monthly report: 0 4 1 * * (1st of month 4 AM IST)
    • Site evolution: 0 2 * * 1 (Weekly Mon 2 AM IST)
  • Customer receives completion email: "You're all set! Here's what happens next..."
  • Customer can log in to fully functional /dashboard
  • Job scheduling failure retried 3x; admin alerted; manual scheduling via admin dashboard
  • DB update failure retried 3x; critical alert; manual fix required
  • Recurring job duplicate prevented by idempotency check on job ID

Agent Context (Pre-conditions)#

  • Required DB state: All onboarding data from Steps 1–18; ClientProfile with onboardingComplete: false; BullMQ queues configured
  • Required env vars: BULLMQ_REDIS_URL, TZ=Asia/Kolkata
  • Required external mocks: None (internal scheduling)
  • Required test data: createMockClientProfile({ onboardingComplete: false })

Verification Commands#

pnpm test:unit -- src/__tests__/unit/onboarding-completion.test.ts
pnpm test:integration -- src/__tests__/integration/onboarding-completion.test.ts
pnpm test:e2e -- e2e/onboarding/step-19-onboarding-complete.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-19-onboarding-complete.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";
import { getBullMQJobs } from "@/__tests__/helpers/bullmq";

test("Step 19: Onboarding completion notification", async () => {
  const { profile } = await createMockUserWithFullProfile();
  // Seed all onboarding artifacts
  await prisma.practice.update({
    where: { id: profile.id },
    data: { directorySlug: "dr-smith-dental-clinic", status: PUBLISHED },
  });
  await prisma.citation.createMany({
    data: Array.from({ length: 24 }, (_, i) => ({
      practiceId: profile.id,
      locationId: profile.id,
      directoryName: `dir-${i}`,
      status: "SUBMITTED",
      submittedAt: new Date(),
      description: `Desc ${i}`,
    })),
  });
  await prisma.gbpAccount.create({
    data: {
      practiceId: profile.id,
      accountEmail: "dr.smith@example.com",
      accessToken: "encrypted",
      refreshToken: "encrypted",
      tokenExpiresAt: new Date(Date.now() + 3600 * 1000),
      scope: ["business.manage"],
      isActive: true,
    },
  });

  await triggerInngestEvent("onboarding-pipeline.complete", { practiceId: profile.id });

  // Assert profile updated
  const updatedProfile = await prisma.clientProfile.findUnique({ where: { id: profile.id } });
  expect(updatedProfile?.onboardingComplete).toBe(true);
  expect(updatedProfile?.status).toBe("ACTIVE");
  expect(updatedProfile?.onboardedAt).toBeInstanceOf(Date);

  // Assert recurring jobs scheduled
  const jobs = await getBullMQJobs(profile.id);
  const jobNames = jobs.map((j) => j.name);
  expect(jobNames).toContain("gbp-post");
  expect(jobNames).toContain("social-post");
  expect(jobNames).toContain("review-monitor");
  expect(jobNames).toContain("nap-check");
  expect(jobNames).toContain("monthly-report");
  expect(jobNames).toContain("profile-refresh");
});

21. Step 20: Admin Notification of New Client#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
adminEmailTemplate Practice with full onboarding data HTML contains all 8 sections: client summary, status, GBP, social, citations, directory profile, action items, deep link src/__tests__/unit/email-templates.test.ts
actionItemFlagging 2 citations failed, GBP pending Action items section lists both; prioritized src/__tests__/unit/email-templates.test.ts

Integration Tests#

Test Setup Action Assertion File
adminNotification Resend mock; onboarding complete Inngest final step → Resend API EmailLog with to: admin@rankflow.in, type: ADMIN_NEW_CLIENT; action items listed src/__tests__/integration/email-delivery.test.ts
slackFallback Resend bounce webhook Slack webhook POST Slack #alerts receives fallback notification src/__tests__/integration/slack-webhook.test.ts

E2E Tests#

Flow Steps Expected End State File
adminNotificationFlow Onboarding complete triggers admin email Admin email delivered within 2 min; all actionable items flagged; deep link functional e2e/onboarding/step-20-admin-notification.spec.ts

Success Criteria (Binary)#

  • Admin email delivered within 2 minutes of onboarding completion to admin@rankflow.in and founder@rankflow.in
  • Email subject: "New Client Onboarded: {BusinessName} ({Plan})"
  • Email contains 8 sections: client summary (name, city, category, plan, trial end), onboarding status, GBP connection status, social connections, citation success rate, directory profile URL, action items, deep link to /admin/clients/{id}
  • All actionable items clearly flagged with priority: "GBP OAuth pending — call client?", "2 citations failed — manual submission needed"
  • Deep link to /admin/clients/{id} functional and returns 200 for admin user
  • Admin email bounce triggers Slack webhook #alerts as backup
  • Missing action items detected by logic → full client data dump included for manual review

Agent Context (Pre-conditions)#

  • Required DB state: All onboarding data from Steps 1–19; EmailLog table; admin user with role: ADMIN
  • Required env vars: RESEND_API_KEY, ADMIN_EMAIL=admin@rankflow.in, FOUNDER_EMAIL=founder@rankflow.in, SLACK_WEBHOOK_URL
  • Required external mocks: Resend API mock; Slack webhook mock
  • Required test data: createMockAdminUser(), createMockEmailLog()

Verification Commands#

pnpm test:unit -- src/__tests__/unit/email-templates.test.ts
pnpm test:integration -- src/__tests__/integration/email-delivery.test.ts
pnpm test:integration -- src/__tests__/integration/slack-webhook.test.ts
pnpm test:e2e -- e2e/onboarding/step-20-admin-notification.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/step-20-admin-notification.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { triggerInngestEvent } from "@/__tests__/helpers/inngest";
import { createMockUserWithFullProfile } from "@/__tests__/helpers/auth";

test("Step 20: Admin notification of new client", async () => {
  const { profile } = await createMockUserWithFullProfile();
  // Complete onboarding
  await prisma.practice.update({
    where: { id: profile.id },
    data: { directorySlug: "dr-smith-dental-clinic", status: PUBLISHED },
  });
  await prisma.citation.createMany({
    data: [
      ...Array.from({ length: 24 }, (_, i) => ({
        practiceId: profile.id,
        locationId: profile.id,
        directoryName: `dir-${i}`,
        status: "SUBMITTED",
        submittedAt: new Date(),
        description: `Desc ${i}`,
      })),
      ...Array.from({ length: 6 }, (_, i) => ({
        practiceId: profile.id,
        locationId: profile.id,
        directoryName: `failed-dir-${i}`,
        status: "FAILED",
        submittedAt: null,
        description: `Failed ${i}`,
      })),
    ],
  });

  await triggerInngestEvent("onboarding-pipeline.notify-admin", { practiceId: profile.id });

  const emailLog = await prisma.emailLog.findFirst({
    where: { type: "ADMIN_NEW_CLIENT", to: { contains: "admin@rankflow.in" } },
  });
  expect(emailLog).not.toBeNull();
  expect(emailLog?.status).toBe("DELIVERED");
  expect(emailLog?.html).toContain("Dr. Smith Dental Clinic");
  expect(emailLog?.html).toContain("STANDARD");
  expect(emailLog?.html).toContain("24/30"); // citation success rate
  expect(emailLog?.html).toContain("/admin/clients/");
  expect(emailLog?.html).toContain("GBP OAuth pending");
  expect(emailLog?.html).toContain("6 citations failed");
});

22. E2E: Full Onboarding Flow (Signup → First Post)#

This section defines the complete end-to-end test that exercises all 20 steps in sequence, measuring the critical metric: 80% completion rate from signup to first GBP post published.

Test Plan & Verification#

E2E Tests#

Flow Steps Expected End State File
fullOnboardingHappyPath 1. Signup 2. Business profile 3. Consent 4. Payment 5. DirectorySlug 6. LP content 7. LP deploy 8. GBP OAuth email 9. GBP OAuth 10. GBP sync 11. Social connect 12. Citations desc 13. Citations submit 14. NAP schedule 15. First GBP post 16. First social posts 17. Welcome email 18. Welcome report 19. Onboarding complete 20. Admin notification All 20 steps complete; onboardingComplete: true; ≥ 24 citations submitted; dashboard functional; 80% completion rate target met e2e/onboarding/full-onboarding.spec.ts
fullOnboardingPartialSocial Same as above but customer skips social connect Onboarding completes; social posts skipped; all other steps succeed e2e/onboarding/full-onboarding-partial.spec.ts
fullOnboardingGbpDeclined Customer declines GBP OAuth initially Onboarding pauses at Step 9; reminder emails sent; admin alerted; can resume later e2e/onboarding/full-onboarding-gbp-declined.spec.ts

Success Criteria (Binary)#

  • Full onboarding from signup to first GBP post completes in < 25 minutes (system automation time)
  • 80% of test runs complete from signup to first GBP post published (target metric)
  • All 20 steps produce correct DB records, external API calls, and emails
  • No data loss or orphaned records across any step
  • All failure paths (declined consent, card declined, GBP denied, no social) handled gracefully
  • Customer can resume onboarding at any step using deep link from email
  • Admin dashboard shows accurate onboarding status for all in-progress clients
  • Total onboarding cost (LLM + API + infrastructure) < Rs 50 per client

Agent Context (Pre-conditions)#

  • Required DB state: Clean PostgreSQL database with all tables migrated; 30 CitationDirectory records seeded
  • Required env vars: All production env vars loaded from .env.test; mock mode enabled for external APIs
  • Required external mocks: Google OAuth, Stripe/Razorpay, Composio, Resend, Claude/GPT/Llama, GBP API, Cloudflare CDN, S3 — all mocked via msw
  • Required test data: Full mock factories for User, ClientProfile, Practice, Location, GbpAccount, GbpLocation, SocialAccount, Citation, DirectoryProfileSection, ContentPiece, ScheduledPost, Report, EmailLog, ConsentLog, Subscription, Billing
  • Required tooling: Playwright browser context, Inngest test client, BullMQ test client, MSW server, Prisma test client

Verification Commands#

# Run the full onboarding E2E test suite
pnpm test:e2e -- e2e/onboarding/full-onboarding.spec.ts

# Run with headed browser for debugging
pnpm test:e2e -- e2e/onboarding/full-onboarding.spec.ts --headed

# Run specific scenario
pnpm test:e2e -- e2e/onboarding/full-onboarding-partial.spec.ts
pnpm test:e2e -- e2e/onboarding/full-onboarding-gbp-declined.spec.ts

Playwright Test Code Outline#

// e2e/onboarding/full-onboarding.spec.ts
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
import { seedCitationDirectories } from "@/__tests__/seeders/citation-directories";
import { setupMockServer } from "@/__tests__/mocks/server";

test.describe("Full Onboarding Flow", () => {
  test.beforeAll(async () => {
    await setupMockServer();
    await seedCitationDirectories();
  });

  test.beforeEach(async () => {
    // Clean DB before each run
    await prisma.$transaction([
      prisma.consentLog.deleteMany(),
      prisma.citation.deleteMany(),
      prisma.socialAccount.deleteMany(),
      prisma.gbpLocation.deleteMany(),
      prisma.gbpAccount.deleteMany(),
      prisma.directoryProfileSection.deleteMany(),
      prisma.contentPiece.deleteMany(),
      prisma.scheduledPost.deleteMany(),
      prisma.location.deleteMany(),
      prisma.clientProfile.deleteMany(),
      prisma.user.deleteMany(),
    ]);
  });

  test("Complete onboarding from signup to first GBP post", async ({ page }) => {
    const startTime = Date.now();

    // Step 1: Signup
    await page.goto("/signup");
    await page.click('[data-testid="plan-standard"]');
    await page.fill('[data-testid="email-input"]', "dr.smith@example.com");
    await page.fill('[data-testid="password-input"]', "Password123");
    await page.fill('[data-testid="confirm-password-input"]', "Password123");
    await page.fill('[data-testid="practice-name-input"]', "Dr. Smith Dental Clinic");
    await page.click('[data-testid="create-account-button"]');
    await expect(page).toHaveURL(/\/onboarding\?step=1/, { timeout: 5000 });

    // Step 2: Business Profile
    await page.fill('[data-testid="business-name"]', "Dr. Smith Dental Clinic");
    await page.selectOption('[data-testid="business-category"]', "DENTIST");
    await page.fill('[data-testid="doctor-name"]', "Dr. John Smith");
    await page.fill('[data-testid="usps"]', "Advanced dental implants, painless root canals");
    await page.click('[data-testid="next-step-2"]');
    await page.fill('[data-testid="primary-address"]', "123 Main Road, Ernakulam");
    await page.selectOption('[data-testid="city"]', "Kochi");
    await page.fill('[data-testid="pin-code"]', "682001");
    await page.fill('[data-testid="primary-phone"]', "+919876543210");
    await page.fill('[data-testid="business-email"]', "contact@drsmithdental.com");
    await page.fill('[data-testid="hours-monday-open"]', "09:00");
    await page.fill('[data-testid="hours-monday-close"]', "19:00");
    await page.click('[data-testid="next-step-3"]');
    await page.fill('[data-testid="service-1"]', "Teeth Cleaning");
    await page.fill('[data-testid="service-2"]', "Root Canal Treatment");
    await page.fill('[data-testid="service-3"]', "Dental Implants");
    await page.click('[data-testid="submit-profile"]');
    await expect(page).toHaveURL(/\/onboarding\?step=consent/, { timeout: 5000 });

    // Step 3: Consent
    await page.check('[data-testid="consent-terms"]');
    await page.check('[data-testid="consent-privacy"]');
    await page.check('[data-testid="consent-gbp"]');
    await page.check('[data-testid="consent-data-processing"]');
    await page.check('[data-testid="consent-citations"]');
    await page.click('[data-testid="agree-and-continue"]');
    await expect(page).toHaveURL(/\/onboarding\?step=payment/, { timeout: 3000 });

    // Step 4: Payment (Stripe)
    const stripeFrame = page.frameLocator('[data-testid="stripe-card-element"] iframe');
    await stripeFrame.locator('[placeholder="Card number"]').fill("4242 4242 4242 4242");
    await stripeFrame.locator('[placeholder="MM / YY"]').fill("12/30");
    await stripeFrame.locator('[placeholder="CVC"]').fill("123");
    await page.click('[data-testid="start-trial-button"]');
    await expect(page).toHaveURL(/\/onboarding\?step=5/, { timeout: 10000 });

    // Steps 5-7: DirectorySlug, Content, Deploy (system automated)
    // In test environment, Inngest steps are triggered and mocked
    await page.waitForTimeout(3000); // Allow system automation

    // Step 8-9: GBP OAuth
    await page.goto("/dashboard/gbp");
    await page.click('[data-testid="connect-gbp-button"]');
    // Simulate OAuth completion
    const state = Buffer.from(JSON.stringify({ practiceId: "auto" })).toString("base64");
    await page.goto(`/api/webhooks/google/oauth-callback?code=mock_code&state=${state}`);
    await expect(page).toHaveURL("/dashboard/gbp?connected=true", { timeout: 5000 });

    // Step 10: GBP Sync (system automated)
    await page.waitForTimeout(2000);

    // Step 11: Social Connect
    await page.goto("/dashboard/social");
    await page.click('[data-testid="connect-instagram"]');
    const socialState = Buffer.from(JSON.stringify({ practiceId: "auto", platform: "INSTAGRAM" })).toString("base64");
    await page.goto(`/api/webhooks/composio/oauth-callback?connectionId=conn_instagram&state=${socialState}`);
    await expect(page).toHaveURL("/dashboard/social?connected=instagram", { timeout: 5000 });

    // Steps 12-14: Citations (system automated)
    await page.waitForTimeout(3000);

    // Steps 15-16: First Posts (system automated)
    await page.waitForTimeout(2000);

    // Step 17-20: Final notifications (system automated)
    await page.waitForTimeout(2000);

    // Final assertions
    const user = await prisma.user.findFirst({ where: { email: "dr.smith@example.com" } });
    const profile = await prisma.clientProfile.findFirst({ where: { userId: user!.id } });
    expect(profile?.onboardingComplete).toBe(true);
    expect(profile?.status).toBe("ACTIVE");

    const citations = await prisma.citation.findMany({ where: { practiceId: profile!.id } });
    const submittedCount = citations.filter((c) => c.status === "SUBMITTED").length;
    expect(submittedCount).toBeGreaterThanOrEqual(24); // 80% success

    const gbpAccount = await prisma.gbpAccount.findFirst({ where: { practiceId: profile!.id } });
    expect(gbpAccount?.isActive).toBe(true);

    const contentPieces = await prisma.contentPiece.findMany({ where: { practiceId: profile!.id } });
    expect(contentPieces.length).toBeGreaterThanOrEqual(1); // At least first GBP post

    const emailLogs = await prisma.emailLog.findMany({ where: { practiceId: profile!.id } });
    expect(emailLogs.length).toBeGreaterThanOrEqual(4); // Welcome, GBP OAuth, Content, Admin

    // Performance assertion
    const duration = Date.now() - startTime;
    expect(duration).toBeLessThan(25 * 60 * 1000); // < 25 minutes
  });
});

23. Success Criteria (Binary)#

Overall Onboarding E2E Success Criteria#

  • SC-01: User + ClientProfile records created within 3 seconds of signup (Step 1)
  • SC-02: Location record created with full NAP and isPrimary: true (Step 2)
  • SC-03: 5 required ConsentLog records stored with IP + timestamp (Step 3)
  • SC-04: Payment method tokenized; Subscription status: trialing; trial_end = +7 days (Step 4)
  • SC-05: Unique directorySlug assigned and immediately resolvable via single domain DNS (Step 5)
  • SC-06: ≥ 8 DirectoryProfileSection records generated; schema markup valid JSON-LD; medical compliance passed (Step 6)
  • SC-07: Directory profile live at directorySlug; HTTP 200; LCP < 2.5s; schema present (Step 7)
  • SC-08: GBP OAuth email delivered; Resend webhook confirms delivered; link clicked within 48h (Step 8)
  • SC-09: GbpAccount created with encrypted tokens; isActive: true; redirect to dashboard (Step 9)
  • SC-10: GbpLocation records synced; business info updated on GBP API; dashboard shows status (Step 10)
  • SC-11: ≥ 1 SocialAccount connected with composioConnectionId and isActive: true (Step 11)
  • SC-12: 30 unique citation descriptions generated; > 80% uniqueness; < Rs 5 cost (Step 12)
  • SC-13: ≥ 24 of 30 citations SUBMITTED with directoryUrl or screenshotUrl proof (Step 13)
  • SC-14: NAP verification event scheduled for +7 days; BullMQ backup cron set (Step 14)
  • SC-15: First GBP post ContentPiece created with status: PENDING_REVIEW and 24h timer (Step 15)
  • SC-16: First social posts ContentPiece created per connected platform (Step 16)
  • SC-17: Welcome email delivered with all 6 sections and UTM-tagged links (Step 17)
  • SC-18: Welcome report PDF generated and uploaded to S3; attached to welcome email (Step 18)
  • SC-19: onboardingComplete: true; status: ACTIVE; 6 recurring jobs scheduled (Step 19)
  • SC-20: Admin email delivered with action items and deep link to /admin/clients/{id} (Step 20)
  • SC-21: 80% completion rate from signup to first GBP post published across all test runs
  • SC-22: Total onboarding time < 25 minutes system automation + customer action time
  • SC-23: Zero unencrypted tokens stored in database at any point
  • SC-24: All customer emails tracked in EmailLog with delivery status
  • SC-25: All failure paths (declined consent, card declined, GBP denied, no social) handled with graceful degradation and customer communication

24. Agent Context & Verification Commands#

Agent Context (Pre-conditions for Full Suite)#

- **Required DB state**: PostgreSQL 15+ with Prisma schema fully migrated; all tables empty except 30 `CitationDirectory` records seeded
- **Required env vars** (`.env.test`):
  - `DATABASE_URL=postgresql://test:test@localhost:5432/rankflow_test`
  - `NEXTAUTH_SECRET=test-secret-12345678901234567890123456789012`
  - `NEXT_PUBLIC_APP_URL=http://localhost:3000`
  - `STRIPE_SECRET_KEY=sk_test_mock`
  - `STRIPE_WEBHOOK_SECRET=whsec_mock`
  - `RAZORPAY_KEY_ID=rzp_test_mock`
  - `RAZORPAY_KEY_SECRET=mock_secret`
  - `RAZORPAY_WEBHOOK_SECRET=mock_webhook_secret`
  - `GOOGLE_CLIENT_ID=mock_google_client_id`
  - `GOOGLE_CLIENT_SECRET=mock_google_client_secret`
  - `GOOGLE_OAUTH_REDIRECT_URI=http://localhost:3000/api/webhooks/google/oauth-callback`
  - `COMPOSIO_API_KEY=mock_composio_key`
  - `ZERNIO_API_KEY=mock_zernio_key`
  - `RESEND_API_KEY=re_mock_1234567890`
  - `RESEND_FROM_EMAIL=onboarding@rankflow.in`
  - `ANTHROPIC_API_KEY=sk-ant-mock`
  - `OPENAI_API_KEY=sk-openai-mock`
  - `TOGETHER_API_KEY=mock_together_key`
  - `HYPERBROWSER_API_KEY=mock_hyperbrowser_key`
  - `S3_REPORTS_BUCKET=rankflow-test-reports`
  - `S3_CITATIONS_BUCKET=rankflow-test-citations`
  - `AWS_ACCESS_KEY_ID=test`
  - `AWS_SECRET_ACCESS_KEY=test`
  - `AWS_REGION=ap-south-1`
  - `CLOUDFLARE_API_TOKEN=mock_cf_token`
  - `WILDCARD_DNS_DOMAIN=rankflow.in`
  - `ENCRYPTION_KEY=aGVsbG8td29ybGQtaGVsbG8td29ybGQtaGVsbG8=` (32-byte base64)
  - `BULLMQ_REDIS_URL=redis://localhost:6379/1`
  - `INNGEST_EVENT_KEY=test-key`
  - `TZ=Asia/Kolkata`
  - `DPDPA_COMPLIANCE_MODE=true`
  - `UPLOAD_MAX_SIZE=2097152`
  - `LLM_ROUTER_TIMEOUT=30000`
  - `GBP_POST_MAX_LENGTH=1500`
  - `AUTO_PUBLISH_HOURS=24`
  - `SOCIAL_POST_MAX_LENGTH_IG=2200`
  - `SOCIAL_POST_MAX_LENGTH_TWITTER=280`
  - `NAP_VERIFY_DELAY_DAYS=7`
  - `PLAYWRIGHT_PDF_TIMEOUT=30000`
  - `ADMIN_EMAIL=admin@rankflow.in`
  - `FOUNDER_EMAIL=founder@rankflow.in`
  - `SLACK_WEBHOOK_URL=https://hooks.slack.com/services/mock`
- **Required external mocks**: All external services mocked via `msw` in `src/__tests__/mocks/server.ts`
- **Required test data**: All factories in `src/__tests__/factories/`; all seeders in `src/__tests__/seeders/`
- **Required test database**: Isolated test database `rankflow_test`; never touches production data
- **Required CI config**: GitHub Actions workflow with PostgreSQL + Redis services; Playwright browsers installed

Verification Commands#

# ─────────────────────────────────────────────
# Setup & Environment Verification
# ─────────────────────────────────────────────

# 1. Verify test database is running and accessible
pnpm prisma migrate reset --force --skip-seed

# 2. Seed citation directories
pnpm tsx src/__tests__/seeders/citation-directories.ts

# 3. Verify mock server starts
pnpm test:unit -- src/__tests__/mocks/server.test.ts

# ─────────────────────────────────────────────
# Step-by-Step Verification (Individual Steps)
# ─────────────────────────────────────────────

# Step 1: Account creation
pnpm test:unit -- src/__tests__/unit/auth.validation.test.ts
pnpm test:integration -- src/__tests__/integration/auth.router.test.ts
pnpm test:e2e -- e2e/onboarding/step-01-signup.spec.ts

# Step 2: Business profile
pnpm test:unit -- src/__tests__/unit/practice.validation.test.ts
pnpm test:integration -- src/__tests__/integration/practice.router.test.ts
pnpm test:e2e -- e2e/onboarding/step-02-business-profile.spec.ts

# Step 3: Consent
pnpm test:unit -- src/__tests__/unit/consent.validation.test.ts
pnpm test:integration -- src/__tests__/integration/consent.router.test.ts
pnpm test:e2e -- e2e/onboarding/step-03-consent.spec.ts

# Step 4: Payment
pnpm test:unit -- src/__tests__/unit/payment.validation.test.ts
pnpm test:integration -- src/__tests__/integration/billing.router.test.ts
pnpm test:integration -- src/__tests__/integration/webhooks.test.ts
pnpm test:e2e -- e2e/onboarding/step-04-payment.spec.ts

# Step 5: DirectorySlug
pnpm test:unit -- src/__tests__/unit/directorySlug.test.ts
pnpm test:integration -- src/__tests__/integration/practice.router.test.ts
pnpm test:e2e -- e2e/onboarding/step-05-profile-creation.spec.ts

# Step 6: LP Content Generation
pnpm test:unit -- src/__tests__/unit/medical-compliance.test.ts
pnpm test:unit -- src/__tests__/unit/schema.validation.test.ts
pnpm test:integration -- src/__tests__/integration/profile-content-generation.test.ts
pnpm test:integration -- src/__tests__/integration/ai-router.test.ts
pnpm test:e2e -- e2e/onboarding/step-06-approval-gate.spec.ts

# Step 7: LP Deployment
pnpm test:unit -- src/__tests__/unit/profile-publish.test.ts
pnpm test:integration -- src/__tests__/integration/profile-publish.test.ts
pnpm test:e2e -- e2e/onboarding/step-07-profile-publish.spec.ts

# Step 8: GBP OAuth Email
pnpm test:unit -- src/__tests__/unit/gbp-oauth.test.ts
pnpm test:unit -- src/__tests__/unit/email-templates.test.ts
pnpm test:integration -- src/__tests__/integration/gbp-oauth.test.ts
pnpm test:integration -- src/__tests__/integration/email-tracking.test.ts
pnpm test:e2e -- e2e/onboarding/step-08-gbp-oauth-email.spec.ts

# Step 9: GBP OAuth Flow
pnpm test:unit -- src/__tests__/unit/crypto.test.ts
pnpm test:unit -- src/__tests__/unit/gbp-oauth.test.ts
pnpm test:integration -- src/__tests__/integration/gbp-oauth.test.ts
pnpm test:e2e -- e2e/onboarding/step-09-gbp-oauth-flow.spec.ts

# Step 10: GBP Location Sync
pnpm test:unit -- src/__tests__/unit/gbp-sync.test.ts
pnpm test:integration -- src/__tests__/integration/gbp-sync.test.ts
pnpm test:e2e -- e2e/onboarding/step-10-gbp-sync.spec.ts

# Step 11: Social Connect
pnpm test:unit -- src/__tests__/unit/social-oauth.test.ts
pnpm test:integration -- src/__tests__/integration/social-connect.test.ts
pnpm test:e2e -- e2e/onboarding/step-11-social-connect.spec.ts

# Step 12: Citation Descriptions
pnpm test:unit -- src/__tests__/unit/citation-description.test.ts
pnpm test:integration -- src/__tests__/integration/citation-builder.test.ts
pnpm test:integration -- src/__tests__/integration/ai-router.test.ts
pnpm test:e2e -- e2e/onboarding/step-12-citation-descriptions.spec.ts

# Step 13: Citation Submission
pnpm test:unit -- src/__tests__/unit/citation-submitter.test.ts
pnpm test:integration -- src/__tests__/integration/citation-builder.test.ts
pnpm test:e2e -- e2e/onboarding/step-13-citation-submit.spec.ts

# Step 14: NAP Verify Schedule
pnpm test:unit -- src/__tests__/unit/inngest-schedule.test.ts
pnpm test:integration -- src/__tests__/integration/citation-builder.test.ts
pnpm test:e2e -- e2e/onboarding/step-14-nap-verify-schedule.spec.ts

# Step 15: First GBP Post
pnpm test:unit -- src/__tests__/unit/gbp-post.test.ts
pnpm test:integration -- src/__tests__/integration/gbp-post.test.ts
pnpm test:e2e -- e2e/onboarding/step-15-first-gbp-post.spec.ts

# Step 16: First Social Posts
pnpm test:unit -- src/__tests__/unit/social-post.test.ts
pnpm test:integration -- src/__tests__/integration/social-post.test.ts
pnpm test:e2e -- e2e/onboarding/step-16-first-social-posts.spec.ts

# Step 17: Welcome Email
pnpm test:unit -- src/__tests__/unit/email-templates.test.ts
pnpm test:integration -- src/__tests__/integration/email-delivery.test.ts
pnpm test:integration -- src/__tests__/integration/email-tracking.test.ts
pnpm test:e2e -- e2e/onboarding/step-17-welcome-email.spec.ts

# Step 18: Welcome Report
pnpm test:unit -- src/__tests__/unit/report-generator.test.ts
pnpm test:integration -- src/__tests__/integration/reporting.test.ts
pnpm test:e2e -- e2e/onboarding/step-18-welcome-report.spec.ts

# Step 19: Onboarding Complete
pnpm test:unit -- src/__tests__/unit/onboarding-completion.test.ts
pnpm test:integration -- src/__tests__/integration/onboarding-completion.test.ts
pnpm test:e2e -- e2e/onboarding/step-19-onboarding-complete.spec.ts

# Step 20: Admin Notification
pnpm test:unit -- src/__tests__/unit/email-templates.test.ts
pnpm test:integration -- src/__tests__/integration/email-delivery.test.ts
pnpm test:integration -- src/__tests__/integration/slack-webhook.test.ts
pnpm test:e2e -- e2e/onboarding/step-20-admin-notification.spec.ts

# ─────────────────────────────────────────────
# Full E2E Flow Verification
# ─────────────────────────────────────────────

# Run all onboarding tests sequentially
pnpm test:e2e -- e2e/onboarding/

# Run full onboarding happy path (target: 80% completion rate)
pnpm test:e2e -- e2e/onboarding/full-onboarding.spec.ts

# Run with tracing for debugging failures
pnpm test:e2e -- e2e/onboarding/full-onboarding.spec.ts --trace on

# Run in CI mode (headless, parallel where safe)
pnpm test:e2e -- e2e/onboarding/ --workers=2

# ─────────────────────────────────────────────
# Combined Verification (All Tiers)
# ─────────────────────────────────────────────

# Run all unit tests
pnpm test:unit

# Run all integration tests
pnpm test:integration

# Run all E2E tests
pnpm test:e2e

# Full test suite (CI pipeline)
pnpm test

Verification Report Template#

After running the full suite, produce a VERIFY.md in docs/test-specs/VERIFY-onboarding-e2e.md:

# Onboarding E2E Verification Report

**Date**: 2026-06-13
**Test Suite**: `TEST-onboarding-e2e.md`
**Runner**: Playwright + Vitest + MSW
**Environment**: `.env.test` with all external services mocked

## Results Summary

| Tier | Total | Passed | Failed | Skipped | Coverage |
|------|-------|--------|--------|---------|----------|
| Unit | 0 | 0 | 0 | 0 | 0% |
| Integration | 0 | 0 | 0 | 0 | 0% |
| E2E | 0 | 0 | 0 | 0 | 0% |

## Success Criteria Checklist

- [ ] SC-01: Account creation within 3s
- [ ] SC-02: Location with full NAP
- [ ] SC-03: 5 consent logs with IP
- [ ] SC-04: Subscription trialing
- [ ] SC-05: DirectorySlug resolvable
- [ ] SC-06: 8+ profile sections + valid schema
- [ ] SC-07: Directory profile live, LCP < 2.5s
- [ ] SC-08: GBP OAuth email delivered
- [ ] SC-09: Encrypted tokens stored
- [ ] SC-10: GBP locations synced
- [ ] SC-11: Social account connected
- [ ] SC-12: 30 unique descriptions
- [ ] SC-13: 24+ citations submitted
- [ ] SC-14: NAP verify scheduled
- [ ] SC-15: First GBP post queued
- [ ] SC-16: First social posts queued
- [ ] SC-17: Welcome email delivered
- [ ] SC-18: Welcome report PDF generated
- [ ] SC-19: Onboarding complete, jobs scheduled
- [ ] SC-20: Admin notification sent
- [ ] SC-21: 80% completion rate achieved
- [ ] SC-22: Total time < 25 minutes
- [ ] SC-23: Zero unencrypted tokens
- [ ] SC-24: All emails tracked
- [ ] SC-25: All failure paths handled

## Failure Analysis

| Step | Failure | Root Cause | Fix Required | Status |
|------|---------|-----------|-------------|--------|
| — | — | — | — | — |

## Sign-off

- [ ] E2E Test Specialist: ___________
- [ ] QA Lead: ___________
- [ ] Engineering Lead: ___________

End of Onboarding E2E Test Specification — RankFlow AI v1.0.0