Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/web/__tests__/unit/checkout-return.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { resolveCheckoutReturnUrls } from "@/lib/checkout-return";

describe("resolveCheckoutReturnUrls", () => {
it("returns null without a return path", () => {
expect(resolveCheckoutReturnUrls("https://cap.so", undefined)).toBeNull();
expect(resolveCheckoutReturnUrls("https://cap.so", "")).toBeNull();
expect(resolveCheckoutReturnUrls("https://cap.so", 42)).toBeNull();
});

it("comes back to the same page after checkout", () => {
expect(
resolveCheckoutReturnUrls(
"https://cap.so/",
"/onboarding/loom?url=https%3A%2F%2Fwww.loom.com%2Fshare%2Fabc",
),
).toEqual({
successUrl:
"https://cap.so/onboarding/loom?url=https%3A%2F%2Fwww.loom.com%2Fshare%2Fabc&upgrade=true&session_id={CHECKOUT_SESSION_ID}",
cancelUrl:
"https://cap.so/onboarding/loom?url=https%3A%2F%2Fwww.loom.com%2Fshare%2Fabc",
});
expect(
resolveCheckoutReturnUrls("https://cap.so", "/onboarding/upload#drop"),
).toEqual({
successUrl:
"https://cap.so/onboarding/upload?upgrade=true&session_id={CHECKOUT_SESSION_ID}#drop",
cancelUrl: "https://cap.so/onboarding/upload#drop",
});
});

it("never leaves the app", () => {
expect(
resolveCheckoutReturnUrls("https://cap.so", "https://evil.example/x"),
).toEqual({
successUrl:
"https://cap.so/dashboard?upgrade=true&session_id={CHECKOUT_SESSION_ID}",
cancelUrl: "https://cap.so/dashboard",
});
expect(
resolveCheckoutReturnUrls("https://cap.so", "//evil.example/x"),
).toEqual({
successUrl:
"https://cap.so/dashboard?upgrade=true&session_id={CHECKOUT_SESSION_ID}",
cancelUrl: "https://cap.so/dashboard",
});
});
});
212 changes: 212 additions & 0 deletions apps/web/__tests__/unit/onboarding-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { describe, expect, it } from "vitest";
import {
maskEmail,
needsOnboarding,
onboardingContinuePath,
onboardingHrefForIntent,
onboardingIntentFromNextPath,
onboardingProgressIndex,
onboardingThemeFromCookie,
resolveOnboardingStep,
resolveOnboardingTheme,
splitFullName,
} from "@/app/(org)/onboarding/onboarding-flow";

describe("needsOnboarding", () => {
it("requires a name", () => {
expect(needsOnboarding({ name: null, onboardingSteps: null })).toBe(true);
expect(needsOnboarding({ name: " ", onboardingSteps: null })).toBe(true);
});

it("keeps new accounts in onboarding until they pick a start", () => {
expect(
needsOnboarding({ name: "Ada", onboardingSteps: { getStarted: false } }),
).toBe(true);
expect(
needsOnboarding({ name: "Ada", onboardingSteps: { getStarted: true } }),
).toBe(false);
});

it("leaves existing named accounts alone", () => {
expect(needsOnboarding({ name: "Ada", onboardingSteps: null })).toBe(false);
expect(needsOnboarding({ name: "Ada", onboardingSteps: {} })).toBe(false);
});
});

describe("resolveOnboardingStep", () => {
it("sends unnamed users to the name step", () => {
expect(resolveOnboardingStep("start", { name: null })).toEqual({
kind: "redirect",
step: "welcome",
});
expect(resolveOnboardingStep("welcome", { name: null })).toEqual({
kind: "render",
step: "welcome",
});
});

it("moves named users past the name step", () => {
expect(resolveOnboardingStep("welcome", { name: "Ada" })).toEqual({
kind: "redirect",
step: "start",
});
expect(resolveOnboardingStep(undefined, { name: "Ada" })).toEqual({
kind: "redirect",
step: "start",
});
});

it("renders the start and path steps", () => {
for (const step of ["start", "loom", "record", "upload"] as const) {
expect(resolveOnboardingStep(step, { name: "Ada" })).toEqual({
kind: "render",
step,
});
}
});

it("redirects the old step urls", () => {
expect(
resolveOnboardingStep("organization-setup", { name: "Ada" }),
).toEqual({ kind: "redirect", step: "start" });
expect(resolveOnboardingStep("custom-domain", { name: "Ada" })).toEqual({
kind: "redirect",
step: "start",
});
expect(resolveOnboardingStep("invite-team", { name: "Ada" })).toEqual({
kind: "redirect",
step: "start",
});
expect(resolveOnboardingStep("download", { name: "Ada" })).toEqual({
kind: "redirect",
step: "record",
});
expect(resolveOnboardingStep("nope", { name: "Ada" })).toEqual({
kind: "redirect",
step: "start",
});
});
});

describe("onboardingIntentFromNextPath", () => {
it("reads a Loom import link", () => {
expect(
onboardingIntentFromNextPath(
"/dashboard/import/loom?url=https%3A%2F%2Fwww.loom.com%2Fshare%2Fabc1234567",
),
).toEqual({
kind: "loom",
loomUrl: "https://www.loom.com/share/abc1234567",
bulk: false,
});
expect(
onboardingIntentFromNextPath("/dashboard/import/loom?mode=csv"),
).toEqual({ kind: "loom", loomUrl: undefined, bulk: true });
});

it("reads upload and record destinations", () => {
expect(onboardingIntentFromNextPath("/dashboard/import/file")).toEqual({
kind: "upload",
});
expect(onboardingIntentFromNextPath("/dashboard/import")).toEqual({
kind: "upload",
});
expect(onboardingIntentFromNextPath("/dashboard/caps/record")).toEqual({
kind: "record",
});
});

it("keeps other destinations to continue to afterwards", () => {
expect(
onboardingIntentFromNextPath("/dashboard/settings/billing?tab=plan"),
).toEqual({
kind: "elsewhere",
path: "/dashboard/settings/billing?tab=plan",
});
});

it("ignores the dashboard home, onboarding and other origins", () => {
expect(onboardingIntentFromNextPath(undefined)).toBeNull();
expect(onboardingIntentFromNextPath("/dashboard")).toBeNull();
expect(onboardingIntentFromNextPath("/dashboard/caps")).toBeNull();
expect(onboardingIntentFromNextPath("/onboarding/start")).toBeNull();
expect(onboardingIntentFromNextPath("/invite/abc123")).toBeNull();
expect(onboardingIntentFromNextPath("https://evil.example/x")).toBeNull();
expect(onboardingIntentFromNextPath("//evil.example/x")).toBeNull();
});
});

describe("onboarding hrefs", () => {
it("routes each intent to its step", () => {
expect(
onboardingHrefForIntent({
kind: "loom",
loomUrl: "https://www.loom.com/share/abc1234567",
bulk: true,
}),
).toBe(
"/onboarding/loom?url=https%3A%2F%2Fwww.loom.com%2Fshare%2Fabc1234567&bulk=1",
);
expect(onboardingHrefForIntent({ kind: "upload" })).toBe(
"/onboarding/upload",
);
expect(onboardingHrefForIntent({ kind: "record" })).toBe(
"/onboarding/record",
);
expect(
onboardingHrefForIntent({ kind: "elsewhere", path: "/dashboard/spaces" }),
).toBe("/onboarding/start");
expect(onboardingHrefForIntent(null)).toBe("/onboarding/start");
});

it("continues to the remembered destination", () => {
expect(
onboardingContinuePath({ kind: "elsewhere", path: "/dashboard/spaces" }),
).toBe("/dashboard/spaces");
expect(onboardingContinuePath(null)).toBe("/dashboard/caps");
});

it("maps steps onto three progress marks", () => {
expect(onboardingProgressIndex("welcome")).toBe(0);
expect(onboardingProgressIndex("start")).toBe(1);
expect(onboardingProgressIndex("loom")).toBe(2);
expect(onboardingProgressIndex("upload")).toBe(2);
});
});

describe("splitFullName", () => {
it("splits the first word from the rest", () => {
expect(splitFullName(" Ada Lovelace King ")).toEqual({
firstName: "Ada",
lastName: "Lovelace King",
});
expect(splitFullName("Cher")).toEqual({ firstName: "Cher", lastName: "" });
});
});

describe("maskEmail", () => {
it("hides most of the mailbox name", () => {
expect(maskEmail("richie@cap.so")).toBe("ri••••@cap.so");
expect(maskEmail("jo@cap.so")).toBe("j•••@cap.so");
expect(maskEmail("nadia.1791348130278@example.com")).toBe(
"na•••••@example.com",
);
expect(maskEmail("not-an-email")).toBe("not-an-email");
});
});

describe("onboarding theme", () => {
it("follows the device unless the app theme was chosen", () => {
expect(onboardingThemeFromCookie(undefined)).toBe("system");
expect(onboardingThemeFromCookie("purple")).toBe("system");
expect(onboardingThemeFromCookie("dark")).toBe("dark");
expect(onboardingThemeFromCookie("light")).toBe("light");
});

it("resolves the device preference", () => {
expect(resolveOnboardingTheme("system", true)).toBe("dark");
expect(resolveOnboardingTheme("system", false)).toBe("light");
expect(resolveOnboardingTheme("light", true)).toBe("light");
expect(resolveOnboardingTheme("dark", false)).toBe("dark");
});
});
115 changes: 102 additions & 13 deletions apps/web/__tests__/unit/onboarding-organization-authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,23 @@ function makeFixture(
const database = {
select: (columns?: Record<string, AnyMySqlColumn>) => ({
from: (table: MySqlTable) => ({
where: async (condition: SQL | undefined) =>
rowsFor(table)
.filter((row) => matchesRow(condition, row))
.map((row) =>
columns
? Object.fromEntries(
Object.entries(columns).map(([key, column]) => [
key,
row[column.name],
]),
)
: { ...row },
),
where: (condition: SQL | undefined) => {
const result = Promise.resolve(
rowsFor(table)
.filter((row) => matchesRow(condition, row))
.map((row) =>
columns
? Object.fromEntries(
Object.entries(columns).map(([key, column]) => [
key,
row[column.name],
]),
)
: { ...row },
),
);
return Object.assign(result, { for: () => result });
},
}),
}),
update: (table: MySqlTable) => ({
Expand Down Expand Up @@ -422,7 +426,92 @@ describe("onboarding organization authorization", () => {
expect(fixture.user.onboardingSteps).toMatchObject({
organizationSetup: true,
download: true,
getStarted: true,
});
},
);
});

describe("onboarding get started", () => {
it("finishes onboarding and personalizes an owner's default organization", async () => {
const fixture = makeFixture({
user: { name: "Jane", onboardingSteps: { getStarted: false } },
organization: { name: "My Organization", ownerId: USER_ID },
});

await fixture.run((service) => service.getStarted({ path: "loom" }));

expect(fixture.organization.name).toBe("Jane's Organization");
expect(fixture.organizations).toHaveLength(1);
expect(fixture.user).toMatchObject({
activeOrganizationId: ORGANIZATION_ID,
defaultOrgId: DEFAULT_ORGANIZATION_ID,
onboardingSteps: {
welcome: true,
organizationSetup: true,
customDomain: true,
inviteTeam: true,
download: true,
getStarted: true,
},
});
expect(fixture.user.onboarding_completed_at).toBeInstanceOf(Date);
});

it("leaves a team's organization untouched for members", async () => {
const fixture = makeFixture({
user: { onboardingSteps: { getStarted: false } },
organization: { name: "My Organization" },
});

await fixture.run((service) => service.getStarted({ path: "record" }));

expect(fixture.organization.name).toBe("My Organization");
expect(fixture.organizations).toHaveLength(1);
expect(fixture.memberships).toEqual([]);
expect(fixture.user).toMatchObject({
activeOrganizationId: ORGANIZATION_ID,
onboardingSteps: { getStarted: true },
});
});

it.each([
{ scenario: "no organization", organizationExists: false },
{ scenario: "a deleted organization", organizationExists: true },
])("creates an organization when the user has $scenario", async (input) => {
const fixture = makeFixture({
organizationExists: input.organizationExists,
organization: { ownerId: USER_ID, tombstoneAt: new Date() },
user: {
name: "Jane",
activeOrganizationId: input.organizationExists
? ORGANIZATION_ID
: Organisation.OrganisationId.make(""),
defaultOrgId: null,
onboardingSteps: { getStarted: false },
},
});

await fixture.run((service) => service.getStarted({ path: "upload" }));

const created = fixture.organizations.find(
(organization) => organization.id !== ORGANIZATION_ID,
);
expect(created).toMatchObject({
name: "Jane's Organization",
ownerId: USER_ID,
});
expect(fixture.memberships).toEqual([
expect.objectContaining({
organizationId: created?.id,
userId: USER_ID,
role: "owner",
}),
]);
expect(fixture.user).toMatchObject({
activeOrganizationId: created?.id,
defaultOrgId: created?.id,
onboardingSteps: { getStarted: true },
});
});
});
Loading
Loading