Files
calendar/apps/web/playwright/organization/organization-creation-flows.e2e.ts
T
sean-brydonGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Hariom Balhara
fa35cc5210 chore: organization onboarding refactor (#24381)
* feat: redirect to new onboarding flow

* Getting started

* Brand details

* Preview organization brands

* Orgs team pages

* Invite team steps

* Move to global zustand store

* Few darkmdoe fixes

* Wip onboarding + stripe flow

* Default plan state

Server Action for gettting slug satus of org

* Remove onboardingId

* Confirmation prompt

* Update old onboarding flow handlers to handle new fields

* update onboarding hook

* Filter out organization section for none -company emails

* Match placeholders to users domain

* Drop migration

* Wip new onboarding intent

* WIP flow for self-hosted. Same service call just split logic

* WIP

* Add TODO

* Use onboarding user type instead of trpc session

* WIP

* WIP

* pass role and team name from onboarding to save in schema

* Add test to ensure role + name + team are persisted into onboarding table

* migrate roles to enum values

* Update ENUM

* Fix type error

* Redirect if flag is disabled

* Remove web

* WIP

* WIP

* Fix migration

* Fix calls

* User onboarding User types instead of trpc session

* Fix factory tests

* Fix flow for self hoste

* Type error

* More type fixes

* Fix handler tests

* Fix enum return type being different

* Use consistant types across the oganization stuff

* Fix

* Use TEAM_BILLING for e2e test

* Refactor is not company email and add tests

* Fix

* Fix

* Refactor flow to submit after form complete

* Fix flow with billing disabled

* Fix tests

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Rename and move test files

* WIP

* Fix types

* Update repo paths + tests

* Move to service folder

* Fix tests

* Fix types

* Remove old test files

* Restore lock

* Fix path

* Fix tests with new paths and factory logic

* Fix updaetdAt

* WIP onboardingID isolation

* Fix e2e test

* verify test

* Code rabbit

* Rename SelfHostedOnboardongService -> SelfHostedOrganizationOnboardingService

* Fix stores

* Fix type error

* Fix types

* remove tsignore

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* NITS

* Add the logic to auto complete admin org when billing enabled

* Fix store being weird

* We need to return the parsed value

* fixes

* sync from db always

* Add onboardingSgtore tests

* fix test

* remove step and status

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-10-18 14:13:30 +00:00

452 lines
17 KiB
TypeScript

import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { uuid } from "short-uuid";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { test } from "../lib/fixtures";
/**
* Utility function to complete the organization onboarding wizard steps
* (about, add-teams, onboard-members)
*/
async function completeOnboardingWizard(
page: Page,
options: {
aboutText?: string;
teams?: string[];
members?: string[];
} = {}
) {
const { aboutText = "Test organization description", teams = ["Engineering"], members = [] } = options;
// Step 1: Complete "About" step
await page.waitForURL("**/settings/organizations/new/about");
await page.locator('textarea[name="about"]').fill(aboutText);
await page.locator("button[type=submit]").click();
// Step 2: Complete "Add Teams" step
await page.waitForURL("**/settings/organizations/new/add-teams");
for (let i = 0; i < teams.length; i++) {
await page.getByTestId(`team.${i}.name`).fill(teams[i]);
if (i < teams.length - 1) {
await page.getByTestId("add_a_team").click();
}
}
await page.locator("button[type=submit]").click();
// Step 3: Complete "Onboard Members" step
await page.waitForURL("**/settings/organizations/new/onboard-members");
// Add members if provided
for (const memberEmail of members) {
await page.locator('[placeholder="colleague\\@company\\.com"]').fill(memberEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-list")).toContainText(memberEmail);
}
// Click publish and wait for response
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
await page.getByTestId("publish-button").click();
const response = await responsePromise;
const responseData = await response.json();
// TRPC batch response is an array with one element
const result = responseData[0].result.data.json;
return result;
}
test.describe("Organization Creation Flows - Comprehensive Suite", () => {
test.afterEach(({ users, orgs }) => {
users.deleteAll();
orgs.deleteAll();
});
test.describe("Admin Handover Flow (Billing Disabled)", () => {
test("Admin creates org for existing user - handover and complete", async ({ page, users }) => {
// Setup: Create admin and future owner
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const ownerEmail = users.trackEmail({
username: "orgowner",
domain: "example.com",
});
const ownerUser = await users.create({
username: "orgowner",
email: ownerEmail,
});
const orgName = "Test Organization";
const orgSlug = `test-org-${uuid()}`.toLowerCase();
// Step 1: Admin fills form
await page.goto("/settings/organizations/new");
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
// If billing enabled, admin sees billing fields but we're testing billing disabled scenario
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("10");
await page.locator("input[name=pricePerSeat]").fill("15");
}
// Submit and wait for intentToCreateOrg
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.locator("button[type=submit]").click(),
]);
// Step 2: Verify admin is on handover page
await page.waitForURL("**/settings/organizations/new/handover");
const onboardingUrlElement = page.getByTestId("onboarding-url");
await expect(onboardingUrlElement).toBeVisible();
const onboardingUrl = await onboardingUrlElement.textContent();
expect(onboardingUrl).toContain("/settings/organizations/new/resume?onboardingId=");
// Extract onboardingId from URL
const onboardingIdMatch = onboardingUrl?.match(/onboardingId=([\w-]+)/);
expect(onboardingIdMatch).toBeTruthy();
const onboardingId = onboardingIdMatch?.[1];
// Step 3: Switch to owner user
await page.context().clearCookies();
await ownerUser.apiLogin();
// Step 4: Owner opens resume URL and completes the onboarding wizard
await page.goto(`/settings/organizations/new/resume?onboardingId=${onboardingId}`);
const memberEmail = users.trackEmail({ username: "member1", domain: "example.com" });
const result = await completeOnboardingWizard(page, {
aboutText: "This is our test organization",
teams: ["Engineering", "Marketing"],
members: [memberEmail],
});
// Verify the organization was created successfully in the API response
expect(result.organizationId).toBeTruthy();
expect(result.name).toBe(orgName);
expect(result.slug).toBe(orgSlug);
});
test("Admin creates org for self - immediate creation", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const orgName = "Admin Org";
const orgSlug = `admin-org-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
// Fill form with admin's own email
await page.locator("input[name=orgOwnerEmail]").fill(admin.email);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("5");
await page.locator("input[name=pricePerSeat]").fill("20");
}
// Submit - admin creating for self goes through wizard
await page.locator("button[type=submit]").click();
// Admin creating for self goes through the onboarding wizard
const result = await completeOnboardingWizard(page, {
aboutText: "Admin's organization",
teams: ["Operations"],
members: [],
});
// Verify the organization was created successfully
expect(result.organizationId).toBeTruthy();
expect(result.name).toBe(orgName);
expect(result.slug).toBe(orgSlug);
});
test("Admin handover URL structure is correct", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const ownerEmail = users.trackEmail({
username: "testowner",
domain: "example.com",
});
await users.create({
username: "testowner",
email: ownerEmail,
});
const orgName = "Handover Test";
const orgSlug = `handover-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("10");
await page.locator("input[name=pricePerSeat]").fill("15");
}
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
await page.locator("button[type=submit]").click();
// Wait for the response
await responsePromise;
// Wait for redirect to handover page
await page.waitForURL("**/settings/organizations/new/handover");
// Wait for handover page elements to be visible (with longer timeout for store to populate)
await expect(page.getByTestId("onboarding-url")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("copy-onboarding-url")).toBeVisible();
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
// Verify URL format
expect(onboardingUrl).toMatch(/\/settings\/organizations\/new\/resume\?onboardingId=[\w-]+/);
// Verify copy button is clickable (toast message may vary by locale)
await page.getByTestId("copy-onboarding-url").click();
});
});
test.describe("Non-Admin User Flow", () => {
test("Regular user creates org through full wizard", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "regularuser",
email: users.trackEmail({ username: "regularuser", domain: "example.com" }),
});
await user.apiLogin();
const orgName = "User Organization";
const orgSlug = `user-org-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
// Verify user email is pre-filled and disabled
const emailInput = page.locator("input[name=orgOwnerEmail]");
await expect(emailInput).toBeDisabled();
await expect(emailInput).toHaveValue(user.email);
// Verify billing fields are NOT visible to regular users
if (IS_TEAM_BILLING_ENABLED) {
await expect(page.locator("input[name=seats]")).not.toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
// Verify "Upgrade to Organizations" UI is shown
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
}
// Fill form
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
await page.locator("button[type=submit]").click();
// Verify navigated to /about step
await page.waitForURL("**/settings/organizations/new/about");
// Complete about step
await page.locator('textarea[name="about"]').fill("User's organization description");
await page.locator("button[type=submit]").click();
// Complete add-teams step
await page.waitForURL("**/settings/organizations/new/add-teams");
await page.getByTestId("team.0.name").fill("Team Alpha");
await page.locator("button[type=submit]").click();
// Complete onboard-members step
await page.waitForURL("**/settings/organizations/new/onboard-members");
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.getByTestId("publish-button").click(),
]);
// In billing enabled mode, should redirect to Stripe or payment
// In billing disabled mode, org created immediately
if (IS_TEAM_BILLING_ENABLED) {
// Would redirect to Stripe checkout
await page.waitForURL(/checkout\.stripe\.com|\/settings\/organizations/);
} else {
// After org creation, session update causes redirect to profile page
// Wait for it to load, then navigate to organizations
await page.waitForURL("**/settings/my-account/profile");
await page.goto("/settings/organizations");
await page.waitForURL(/\/settings\/organizations/);
}
});
test("Regular user - email field is disabled", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "testuser",
email: users.trackEmail({ username: "testuser", domain: "company.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
// Verify email field state
const emailInput = page.locator("input[name=orgOwnerEmail]");
await expect(emailInput).toBeVisible();
await expect(emailInput).toBeDisabled();
await expect(emailInput).toHaveValue(user.email);
// Verify name and slug are derived from email domain
const nameInput = page.locator("input[name=name]");
const slugInput = page.locator("input[name=slug]");
// Name should be capitalized version of domain
const nameValue = await nameInput.inputValue();
expect(nameValue).toBeTruthy();
expect(nameValue).toMatch(/company/i);
// Slug should be domain-based
const slugValue = await slugInput.inputValue();
expect(slugValue).toBeTruthy();
expect(slugValue).toContain("company");
});
});
test.describe("Form Validation", () => {
test("Admin form - required fields validation", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
await page.goto("/settings/organizations/new");
// Submit empty form
await page.locator("button[type=submit]").click();
// Should show validation errors
await expect(page.getByTestId("field-error")).toHaveCount(2); // name and slug required
});
test("Slug already taken shows error", async ({ page, users, orgs }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
// Create an existing org with a specific slug
const existingSlug = `existing-${uuid()}`.toLowerCase();
await orgs.create({
name: "Existing Org",
slug: existingSlug,
});
await page.goto("/settings/organizations/new");
const ownerEmail = users.trackEmail({ username: "owner", domain: "example.com" });
await users.create({ username: "owner", email: ownerEmail });
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill("New Org");
await page.locator("input[name=slug]").fill(existingSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("5");
await page.locator("input[name=pricePerSeat]").fill("10");
}
await page.locator("button[type=submit]").click();
// Should show slug taken error
await expect(page.getByText(/url.*taken|already.*exists/i)).toBeVisible();
});
});
test.describe("Navigation Between Steps", () => {
test("User can navigate through wizard steps", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "wizarduser",
email: users.trackEmail({ username: "wizarduser", domain: "example.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
const orgName = "Wizard Test";
const orgSlug = `wizard-${uuid()}`.toLowerCase();
// Step 1
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
await page.locator("button[type=submit]").click();
// Step 2: About
await page.waitForURL("**/settings/organizations/new/about");
await page.locator('textarea[name="about"]').fill("Wizard test org");
await page.locator("button[type=submit]").click();
// Step 3: Add teams
await page.waitForURL("**/settings/organizations/new/add-teams");
await page.getByTestId("team.0.name").fill("Team 1");
await page.locator("button[type=submit]").click();
// Step 4: Onboard members
await page.waitForURL("**/settings/organizations/new/onboard-members");
// Verify wizard shows correct step numbers
// The WizardLayout component shows currentStep/maxSteps
// Just verify we reached the last step
await expect(page.getByTestId("publish-button")).toBeVisible();
});
});
test.describe("Billing Field Visibility", () => {
test("Admin sees billing fields when billing enabled", async ({ page, users }) => {
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
await page.goto("/settings/organizations/new");
// Verify billing fields are visible for admin
await expect(page.locator("input[name=seats]")).toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).toBeVisible();
await expect(page.locator("#billingPeriod")).toBeVisible();
// Verify toggle between monthly/annual
await expect(page.getByText("Monthly")).toBeVisible();
await expect(page.getByText("Annually")).toBeVisible();
});
test("Non-admin sees pricing UI but not billing fields", async ({ page, users }) => {
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "pricinguser",
email: users.trackEmail({ username: "pricinguser", domain: "example.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
// Should NOT see admin billing fields
await expect(page.locator("input[name=seats]")).not.toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
await expect(page.locator("#billingPeriod")).not.toBeVisible();
// Should see upgrade/pricing UI
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
await expect(page.getByText(/organization/i)).toBeVisible();
});
});
});