Files
calendar/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts
sean-brydonGitHubcoderabbitai[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 <[email protected]>
2025-10-18 14:13:30 +00:00

478 lines
15 KiB
TypeScript

import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService";
import { BillingPeriod, UserPermissionRole, CreationSource } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { intentToCreateOrgHandler } from "./intentToCreateOrg.handler";
vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({
LicenseKeySingleton: {
getInstance: vi.fn(),
},
}));
vi.mock("@calcom/features/ee/organizations/lib/OrganizationPaymentService");
const mockInput = {
name: "Test Org",
slug: "test-org",
orgOwnerEmail: "[email protected]",
billingPeriod: "MONTHLY",
seats: 5,
pricePerSeat: 20,
isPlatform: false,
creationSource: "WEBAPP",
};
// Helper functions for creating test data
async function createTestUser(data: {
email: string;
name?: string;
username?: string;
role?: UserPermissionRole;
completedOnboarding?: boolean;
emailVerified?: Date | null;
}) {
return prismock.user.create({
data: {
email: data.email,
name: data.name || "Test User",
username: data.username || "testuser",
role: data.role || UserPermissionRole.USER,
completedOnboarding: data.completedOnboarding ?? false,
emailVerified: data.emailVerified ?? null,
},
});
}
describe("intentToCreateOrgHandler", () => {
beforeEach(async () => {
vi.resetAllMocks();
await prismock.reset();
vi.mocked(OrganizationPaymentService).mockImplementation(() => {
return {
createOrganizationOnboarding: vi.fn().mockImplementation(async (data: any) => {
return await prismock.organizationOnboarding.create({
data: {
id: "onboarding-123",
name: data.name,
slug: data.slug,
orgOwnerEmail: data.orgOwnerEmail,
seats: data.seats ?? 10,
pricePerSeat: data.pricePerSeat ?? 15,
billingPeriod: data.billingPeriod ?? BillingPeriod.MONTHLY,
isComplete: false,
stripeCustomerId: null,
createdById: data.createdByUserId,
teams: data.teams ?? [],
invitedMembers: data.invitedMembers ?? [],
isPlatform: data.isPlatform ?? false,
},
});
}),
createPaymentIntent: vi.fn().mockResolvedValue({
checkoutUrl: "https://stripe.com/checkout/session",
organizationOnboarding: {},
subscription: {},
sessionId: "session-123",
}),
} as any;
});
});
describe("hosted", () => {
beforeEach(() => {
vi.mock("@calcom/lib/constants", async () => {
const actualImport = await vi.importActual("@calcom/lib/constants");
return {
...actualImport,
IS_SELF_HOSTED: false,
};
});
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
incrementUsage: vi.fn().mockResolvedValue(undefined),
});
process.env.STRIPE_ORG_PRODUCT_ID = "prod_test123";
process.env.STRIPE_ORG_MONTHLY_PRICE_ID = "price_test123";
});
it("should allow admin user to create org for another user", async () => {
// Create admin user
const adminUser = await createTestUser({
email: "[email protected]",
role: UserPermissionRole.ADMIN,
});
// Create target org owner with completed onboarding and verified email
await createTestUser({
email: mockInput.orgOwnerEmail,
completedOnboarding: true,
emailVerified: new Date(),
});
const result = await intentToCreateOrgHandler({
input: mockInput,
ctx: {
user: adminUser,
},
});
// Admin creating for different user with no teams/invites triggers handover
expect(result).toEqual({
userId: expect.any(Number),
orgOwnerEmail: mockInput.orgOwnerEmail,
name: mockInput.name,
slug: mockInput.slug,
seats: mockInput.seats,
pricePerSeat: mockInput.pricePerSeat,
billingPeriod: mockInput.billingPeriod,
isPlatform: mockInput.isPlatform,
organizationOnboardingId: expect.any(String),
checkoutUrl: null,
organizationId: null, // Not created yet - handover flow
handoverUrl: expect.stringContaining("/settings/organizations/new/resume?onboardingId="),
});
// Verify organization onboarding was created
const organizationOnboarding = await prismock.organizationOnboarding.findFirst({
where: {
slug: mockInput.slug,
},
});
expect(organizationOnboarding).toBeDefined();
expect(organizationOnboarding?.name).toBe(mockInput.name);
expect(organizationOnboarding?.slug).toBe(mockInput.slug);
expect(organizationOnboarding?.orgOwnerEmail).toBe(mockInput.orgOwnerEmail);
});
it("should allow user to create org for themselves", async () => {
// Create user
const user = await createTestUser({
email: mockInput.orgOwnerEmail,
completedOnboarding: true,
emailVerified: new Date(),
});
const result = await intentToCreateOrgHandler({
input: {
...mockInput,
// Use default payment settings
seats: undefined,
pricePerSeat: undefined,
},
ctx: {
user,
},
});
expect(result.userId).toBe(user.id);
expect(result.orgOwnerEmail).toBe(user.email);
});
it("should throw unauthorized error when no user is logged in", async () => {
await expect(
intentToCreateOrgHandler({
input: mockInput,
ctx: {
user: null as any,
},
})
).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized." }));
});
it("should throw forbidden error when non-admin tries to create org for another user", async () => {
// Create non-admin user
const nonAdminUser = await createTestUser({
email: "[email protected]",
role: UserPermissionRole.USER,
});
await expect(
intentToCreateOrgHandler({
input: mockInput,
ctx: {
user: nonAdminUser,
},
})
).rejects.toThrow(
new TRPCError({
code: "FORBIDDEN",
message: "You can only create organization where you are the owner",
})
);
});
it("should throw error when target user is not found", async () => {
// Create admin user
const adminUser = await createTestUser({
email: "[email protected]",
role: UserPermissionRole.ADMIN,
});
await expect(
intentToCreateOrgHandler({
input: mockInput,
ctx: {
user: adminUser,
},
})
).rejects.toThrow(
new TRPCError({
code: "BAD_REQUEST",
message: `No user found with email ${mockInput.orgOwnerEmail}`,
})
);
});
it("should throw error when organization onboarding already exists and is complete", async () => {
// Create admin user
const adminUser = await createTestUser({
email: "[email protected]",
role: UserPermissionRole.ADMIN,
});
// Create target org owner
await createTestUser({
email: mockInput.orgOwnerEmail,
completedOnboarding: true,
emailVerified: new Date(),
});
// Create existing COMPLETE organization onboarding
await prismock.organizationOnboarding.create({
data: {
name: mockInput.name,
slug: mockInput.slug,
orgOwnerEmail: mockInput.orgOwnerEmail,
billingPeriod: mockInput.billingPeriod,
seats: mockInput.seats,
pricePerSeat: mockInput.pricePerSeat,
createdById: adminUser.id,
isComplete: true, // Must be complete to throw error
},
});
await expect(
intentToCreateOrgHandler({
input: mockInput,
ctx: {
user: adminUser,
},
})
).rejects.toThrow("organization_onboarding_already_exists");
});
});
// This endpoint doesn't handle platform organizations as that is completely different flow at the moment
// eslint-disable-next-line playwright/no-skipped-test
describe.skip("platform organization tests");
describe("self hosted", () => {
beforeEach(() => {
vi.mock("@calcom/lib/constants", async () => {
const actualImport = await vi.importActual("@calcom/lib/constants");
return {
...actualImport,
IS_SELF_HOSTED: true,
};
});
});
it("should throw error when license is not valid", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(false),
incrementUsage: vi.fn().mockResolvedValue(undefined),
});
const adminUser = await createTestUser({
email: "[email protected]",
role: UserPermissionRole.ADMIN,
});
await expect(
intentToCreateOrgHandler({
input: mockInput,
ctx: {
user: adminUser,
},
})
).rejects.toThrow("License is not valid");
});
it("should allow admin user to create org for another user", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
incrementUsage: vi.fn().mockResolvedValue(undefined),
});
// Create admin user
const adminUser = await createTestUser({
email: "[email protected]",
role: UserPermissionRole.ADMIN,
});
// Create target org owner with completed onboarding and verified email
await createTestUser({
email: mockInput.orgOwnerEmail,
completedOnboarding: true,
emailVerified: new Date(),
});
const result = await intentToCreateOrgHandler({
input: mockInput,
ctx: {
user: adminUser,
},
});
// Admin creating for different user with no teams/invites triggers handover (same as hosted)
expect(result).toEqual({
userId: expect.any(Number),
orgOwnerEmail: mockInput.orgOwnerEmail,
name: mockInput.name,
slug: mockInput.slug,
seats: mockInput.seats,
pricePerSeat: mockInput.pricePerSeat,
billingPeriod: mockInput.billingPeriod,
isPlatform: mockInput.isPlatform,
organizationOnboardingId: expect.any(String),
checkoutUrl: null,
organizationId: null, // Not created yet - handover flow
handoverUrl: expect.stringContaining("/settings/organizations/new/resume?onboardingId="),
});
// Verify organization onboarding was created
const organizationOnboarding = await prismock.organizationOnboarding.findFirst({
where: {
slug: mockInput.slug,
},
});
expect(organizationOnboarding).toBeDefined();
expect(organizationOnboarding?.name).toBe(mockInput.name);
expect(organizationOnboarding?.slug).toBe(mockInput.slug);
expect(organizationOnboarding?.orgOwnerEmail).toBe(mockInput.orgOwnerEmail);
});
it("should handle teams and invites in the request", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
incrementUsage: vi.fn().mockResolvedValue(undefined),
});
const adminUser = await createTestUser({
email: "[email protected]",
role: UserPermissionRole.ADMIN,
});
await createTestUser({
email: mockInput.orgOwnerEmail,
completedOnboarding: true,
emailVerified: new Date(),
});
const inputWithTeamsAndInvites = {
...mockInput,
teams: [
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
],
invitedMembers: [
{ email: "[email protected]", name: "Member 1" },
{ email: "[email protected]", name: "Member 2" },
],
};
const result = await intentToCreateOrgHandler({
input: inputWithTeamsAndInvites,
ctx: {
user: adminUser,
},
});
expect(result.organizationOnboardingId).toBeDefined();
const organizationOnboarding = await prismock.organizationOnboarding.findFirst({
where: {
slug: mockInput.slug,
},
});
expect(organizationOnboarding).toBeDefined();
expect(organizationOnboarding?.teams).toEqual(inputWithTeamsAndInvites.teams);
expect(organizationOnboarding?.invitedMembers).toEqual(inputWithTeamsAndInvites.invitedMembers);
});
it("should preserve teamName, teamId, and role in invites payload", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
incrementUsage: vi.fn().mockResolvedValue(undefined),
});
const adminUser = await createTestUser({
email: "[email protected]",
role: UserPermissionRole.ADMIN,
});
await createTestUser({
email: mockInput.orgOwnerEmail,
completedOnboarding: true,
emailVerified: new Date(),
});
// This matches the exact payload from the frontend
const inputWithTeamsAndInvites = {
...mockInput,
teams: [
{ id: -1, name: "New", isBeingMigrated: false, slug: null },
{ id: -1, name: "team", isBeingMigrated: false, slug: null },
],
invitedMembers: [
{ email: "[email protected]", teamName: "new", teamId: -1, role: "ADMIN" },
{ email: "[email protected]", teamName: "team", teamId: -1, role: "ADMIN" },
],
};
const result = await intentToCreateOrgHandler({
input: inputWithTeamsAndInvites,
ctx: {
user: adminUser,
},
});
expect(result.organizationOnboardingId).toBeDefined();
const organizationOnboarding = await prismock.organizationOnboarding.findFirst({
where: {
slug: mockInput.slug,
},
});
expect(organizationOnboarding).toBeDefined();
expect(organizationOnboarding?.teams).toEqual(inputWithTeamsAndInvites.teams);
// Verify invitedMembers are stored with all fields including teamName, teamId, and role
expect(organizationOnboarding?.invitedMembers).toBeDefined();
expect(organizationOnboarding?.invitedMembers).toHaveLength(2);
const invitedMembers = organizationOnboarding?.invitedMembers as any[];
expect(invitedMembers[0]).toMatchObject({
email: "[email protected]",
teamName: "new",
teamId: -1,
role: "ADMIN",
});
expect(invitedMembers[1]).toMatchObject({
email: "[email protected]",
teamName: "team",
teamId: -1,
role: "ADMIN",
});
});
});
});