Skip to content
Open
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
70 changes: 70 additions & 0 deletions apps/web/__tests__/unit/share-call-to-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
DEFAULT_CTA_COLOR,
normalizeCallToActionUrl,
normalizeHexColor,
parseDefaultCallToAction,
parseShareCallToAction,
readableTextColor,
resolveShareCallToAction,
type ShareCallToAction,
shouldShowCornerCard,
toStoredCallToAction,
Expand Down Expand Up @@ -281,3 +283,71 @@ describe("shouldShowCornerCard", () => {
).toBe(false);
});
});

describe("resolveShareCallToAction", () => {
const own = { label: "Watch the demo", url: "https://example.com/demo" };
const fallback = {
label: "Book a call",
url: "https://cal.com/me",
color: "#12A150",
};

it("prefers the video's own call to action over the owner's default", () => {
const resolved = resolveShareCallToAction(
{ callToAction: own },
{ defaultCallToAction: fallback },
);
expect(resolved.source).toBe("video");
expect(resolved.callToAction?.label).toBe("Watch the demo");
});

it("falls back to the owner's default when the video has none", () => {
for (const settings of [null, {}, { callToAction: null }]) {
const resolved = resolveShareCallToAction(settings, {
defaultCallToAction: fallback,
});
expect(resolved.source).toBe("default");
expect(resolved.callToAction).toMatchObject({
label: "Book a call",
url: "https://cal.com/me",
color: "#12A150",
showWhilePlaying: true,
});
}
});

it("keeps a video that was turned off free of the default", () => {
expect(
resolveShareCallToAction(
{ callToAction: false },
{ defaultCallToAction: fallback },
),
).toEqual({ callToAction: null, source: null });
});

it("ignores an invalid stored default", () => {
expect(
resolveShareCallToAction(
{},
{ defaultCallToAction: { label: "Go", url: "javascript:alert(1)" } },
),
).toEqual({ callToAction: null, source: null });
expect(resolveShareCallToAction({}, null)).toEqual({
callToAction: null,
source: null,
});
});
});

describe("parseDefaultCallToAction", () => {
it("reads the default from user preferences", () => {
expect(
parseDefaultCallToAction({
notifications: {},
defaultCallToAction: { label: "Reply to me", url: "mailto:a@b.co" },
}),
).toMatchObject({ label: "Reply to me", url: "mailto:a@b.co" });
expect(parseDefaultCallToAction({ notifications: {} })).toBeNull();
expect(parseDefaultCallToAction(null)).toBeNull();
});
});
55 changes: 54 additions & 1 deletion apps/web/__tests__/unit/share-page-loading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ function arrangeRows(rows: ReturnType<typeof createVideo>[]) {
from: () => query,
leftJoin: () => query,
innerJoin: () => query,
where: () => Promise.resolve(result),
where: () =>
Object.assign(Promise.resolve(result), {
limit: () => Promise.resolve(result),
}),
};
return query;
});
Expand Down Expand Up @@ -192,6 +195,8 @@ async function renderAuthorizedContent() {
sharedOrganizations: unknown[];
ownerIsOverShareLimit: boolean;
callToAction: { label: string; url: string } | null;
callToActionSource: "video" | "default" | null;
defaultCallToAction: { label: string; url: string } | null;
};
}>;
}>;
Expand Down Expand Up @@ -264,6 +269,54 @@ describe("share page loading", () => {
},
);

const ownerDefault = { label: "Book a call", url: "https://cal.com/owner" };

const withDefault = (videoSettings: unknown) =>
({
...createVideo(),
videoSettings,
owner: {
id: "owner",
image: null,
preferences: { defaultCallToAction: ownerDefault },
},
}) as unknown as ReturnType<typeof createVideo>;

it("shows the owner's default on a video without its own call to action", async () => {
mocks.ownerIsPro = true;
arrangeRows([withDefault({})]);
const data = (await renderAuthorizedContent()).props.children.props.data;
expect(data.callToAction).toMatchObject(ownerDefault);
expect(data.callToActionSource).toBe("default");
});

it("keeps a video that was turned off free of the owner's default", async () => {
mocks.ownerIsPro = true;
arrangeRows([withDefault({ callToAction: false })]);
const data = (await renderAuthorizedContent()).props.children.props.data;
expect(data.callToAction).toBeNull();
expect(data.callToActionSource).toBeNull();
});

it("doesn't apply the default for an owner without Pro", async () => {
arrangeRows([withDefault({})]);
const data = (await renderAuthorizedContent()).props.children.props.data;
expect(data.callToAction).toBeNull();
});

it("only sends the default itself to the owner", async () => {
mocks.ownerIsPro = true;
arrangeRows([withDefault({})]);
const viewerData = (await renderAuthorizedContent()).props.children.props
.data;
expect(viewerData.defaultCallToAction).toBeNull();

mocks.user.mockResolvedValue({ id: "owner" });
const ownerData = (await renderAuthorizedContent()).props.children.props
.data;
expect(ownerData.defaultCallToAction).toMatchObject(ownerDefault);
});

it("reuses the organization sharing query for the header and video data", async () => {
mocks.sharedOrganizations = [
{ id: "shared", organizationId: "shared", name: "Shared team" },
Expand Down
134 changes: 126 additions & 8 deletions apps/web/__tests__/unit/video-call-to-action-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
} | null,
video: null as { ownerId: string } | null,
updates: [] as unknown[],
failTransactionWrite: null as number | null,
}));

vi.mock("@cap/env", () => ({
Expand All @@ -32,19 +33,43 @@ vi.mock("@cap/database", () => ({
},
}),
}),
transaction: async (
run: (tx: { update: () => unknown }) => Promise<void>,
) => {
const staged: unknown[] = [];
await run({
update: () => ({
set: (values: unknown) => ({
where: async () => {
if (mocks.failTransactionWrite === staged.length) {
throw new Error("write failed");
}
staged.push(values);
},
}),
}),
});
mocks.updates.push(...staged);
},
}),
}));

import type { Video } from "@cap/web-domain";
import { updateVideoCallToAction } from "@/actions/videos/call-to-action";
import {
updateDefaultCallToAction,
updateVideoCallToAction,
} from "@/actions/videos/call-to-action";

const videoId = "video123" as Video.VideoId;

const sqlText = (value: unknown): string => {
const chunks = (value as { queryChunks?: unknown[] }).queryChunks ?? [];
return chunks
.map((chunk) => {
.map((chunk): string => {
if (typeof chunk === "string") return chunk;
if (chunk && typeof chunk === "object" && "queryChunks" in chunk) {
return sqlText(chunk);
}
if (chunk && typeof chunk === "object" && "value" in chunk) {
const inner = (chunk as { value: unknown }).value;
return Array.isArray(inner) ? inner.join("") : String(inner);
Expand All @@ -59,6 +84,7 @@ describe("updateVideoCallToAction", () => {
mocks.user = { id: "owner", stripeSubscriptionStatus: "active" };
mocks.video = { ownerId: "owner" };
mocks.updates = [];
mocks.failTransactionWrite = null;
});

it("requires a signed-in user", async () => {
Expand Down Expand Up @@ -135,24 +161,116 @@ describe("updateVideoCallToAction", () => {
color: "#12A150",
showWhilePlaying: false,
},
source: "video",
});
expect(mocks.updates).toHaveLength(1);
const settings = sqlText(
(mocks.updates[0] as { settings: unknown }).settings,
);
expect(settings).toContain("JSON_MERGE_PATCH(");
expect(settings).toContain('"callToAction":null');
expect(settings).toContain("JSON_SET(");
expect(settings).toContain("'$.callToAction'");
expect(settings).toContain('"url":"https://cal.com/demo"');
});

it("removes only the call to action key", async () => {
it("turns calls to action off for the video so a default can't apply", async () => {
const result = await updateVideoCallToAction(videoId, null);
expect(result).toEqual({ success: true, callToAction: null });
expect(result).toEqual({ success: true, callToAction: null, source: null });
expect(mocks.updates).toHaveLength(1);
const settings = sqlText(
(mocks.updates[0] as { settings: unknown }).settings,
);
expect(settings).toContain("JSON_MERGE_PATCH(");
expect(settings).toContain('{"callToAction":null}');
expect(settings).toContain("'$.callToAction'");
expect(settings).toContain("CAST('false' AS JSON)");
expect(settings).not.toContain('"url"');
});

it("saves as the owner's default and lets the video follow it", async () => {
const result = await updateVideoCallToAction(
videoId,
{ label: "Book a call", url: "cal.com/demo" },
{ asDefault: true },
);
expect(result).toMatchObject({ success: true, source: "default" });
expect(mocks.updates).toHaveLength(2);
const preferences = sqlText(
(mocks.updates[0] as { preferences: unknown }).preferences,
);
expect(preferences).toContain("'$.defaultCallToAction'");
expect(preferences).toContain('"url":"https://cal.com/demo"');
const settings = sqlText(
(mocks.updates[1] as { settings: unknown }).settings,
);
expect(settings).toContain("JSON_REMOVE(");
expect(settings).toContain("'$.callToAction'");
});

it("leaves the default unchanged when clearing the video's own copy fails", async () => {
mocks.failTransactionWrite = 1;
await expect(
updateVideoCallToAction(
videoId,
{ label: "Book a call", url: "cal.com/demo" },
{ asDefault: true },
),
).rejects.toThrow("write failed");
expect(mocks.updates).toHaveLength(0);
});
});

describe("updateDefaultCallToAction", () => {
beforeEach(() => {
mocks.user = { id: "owner", stripeSubscriptionStatus: "active" };
mocks.updates = [];
});

it("requires a signed-in user", async () => {
mocks.user = null;
await expect(updateDefaultCallToAction(null)).rejects.toThrow(
"Unauthorized",
);
expect(mocks.updates).toHaveLength(0);
});

it("stores the normalized default in the user's preferences", async () => {
const result = await updateDefaultCallToAction({
label: "Try it free",
url: "cap.so",
});
expect(result).toMatchObject({
success: true,
callToAction: { label: "Try it free", url: "https://cap.so/" },
});
const preferences = sqlText(
(mocks.updates[0] as { preferences: unknown }).preferences,
);
expect(preferences).toContain("JSON_SET(");
expect(preferences).toContain("'$.defaultCallToAction'");
// New users' preferences are a JSON null, which COALESCE wouldn't replace.
expect(preferences).toContain("JSON_TYPE(");
expect(preferences).not.toContain("COALESCE(");
});

it("needs Pro to set a default but not to remove one", async () => {
mocks.user = { id: "owner", stripeSubscriptionStatus: "canceled" };
await expect(
updateDefaultCallToAction({ label: "Go", url: "example.com" }),
).resolves.toEqual({ success: false, errors: {}, upgradeRequired: true });
expect(mocks.updates).toHaveLength(0);

await expect(updateDefaultCallToAction(null)).resolves.toEqual({
success: true,
callToAction: null,
});
const preferences = sqlText(
(mocks.updates[0] as { preferences: unknown }).preferences,
);
expect(preferences).toContain("JSON_REMOVE(");
expect(preferences).toContain("'$.defaultCallToAction'");
});

it("returns field errors without writing invalid input", async () => {
const result = await updateDefaultCallToAction({ label: "", url: "" });
expect(result.success).toBe(false);
expect(mocks.updates).toHaveLength(0);
});
});
Loading
Loading