Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { render, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";

const payloads: any[] = [];
const mockExec = jest.fn();

jest.mock("@components/contexts", () => {
const React = require("react");

return {
AddressContext: React.createContext({
backend: "http://localhost:3000",
frontend: "http://localhost:3000",
}),
ThemeContext: React.createContext({
theme: {
id: "test",
name: "Test",
theme: {},
},
setTheme: jest.fn(),
}),
};
});

jest.mock("@components/public/payments/checkout", () => ({
__esModule: true,
default: ({ product }: { product: { name: string } }) => (
<div>{product.name}</div>
),
}));

jest.mock("@courselit/components-library", () => ({
useToast: () => ({
toast: jest.fn(),
}),
}));

jest.mock("@courselit/page-primitives", () => ({
Header1: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
}));

jest.mock("@courselit/utils", () => ({
FetchBuilder: jest.fn().mockImplementation(() => ({
setUrl: jest.fn().mockReturnThis(),
setPayload: jest.fn(function (payload) {
payloads.push(payload);
return this;
}),
setIsGraphQLEndpoint: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnThis(),
exec: mockExec,
})),
}));

jest.mock("next/navigation", () => ({
notFound: jest.fn(),
useSearchParams: () => new URLSearchParams("type=course&id=course-1"),
}));

import ProductCheckout from "../product";

describe("ProductCheckout", () => {
beforeEach(() => {
payloads.length = 0;
jest.clearAllMocks();
});

it("uses public course visibility and shows 404 when checkout course is unavailable", async () => {
const { notFound } = jest.requireMock("next/navigation");
mockExec.mockResolvedValueOnce({
course: null,
loginProviders: [],
});

render(<ProductCheckout />);

await waitFor(() => {
expect(payloads[0].query).toContain(
"course: getCourse(id: $id, asGuest: true)",
);
});

await waitFor(() => {
expect(notFound).toHaveBeenCalled();
});
});
});
5 changes: 1 addition & 4 deletions apps/web/app/(with-contexts)/(with-layout)/checkout/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { ThemeContext } from "@components/contexts";
import { Header1, Section } from "@courselit/page-primitives";
import { Section } from "@courselit/page-primitives";
import { useContext } from "react";
import ProductCheckout from "./product";

Expand All @@ -10,9 +10,6 @@ export default function CheckoutPage() {
return (
<Section theme={theme.theme}>
<div className="flex flex-col">
<Header1 theme={theme.theme} className="mb-8">
Checkout
</Header1>
<ProductCheckout />
</div>
</Section>
Expand Down
48 changes: 26 additions & 22 deletions apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import type { MembershipEntityType } from "@courselit/common-models";
import { useToast } from "@courselit/components-library";
import { FetchBuilder } from "@courselit/utils";
import { TOAST_TITLE_ERROR } from "@ui-config/strings";
import { useSearchParams } from "next/navigation";
import { notFound, useSearchParams } from "next/navigation";
import { useCallback, useContext, useEffect, useState } from "react";
import type { RuntimeLoginProvider } from "@/lib/login-providers";
import { Header1 } from "@courselit/page-primitives";
import { ThemeContext } from "@components/contexts";

const { MembershipEntityType } = Constants;

export default function ProductCheckout() {
const address = useContext(AddressContext);
const { theme } = useContext(ThemeContext);
const searchParams = useSearchParams();
const entityId = searchParams?.get("id");
const entityType = searchParams?.get("type");
Expand All @@ -23,6 +26,7 @@ export default function ProductCheckout() {
const [product, setProduct] = useState<Product | null>(null);
const [paymentPlans, setPaymentPlans] = useState<PaymentPlan[]>([]);
const [includedProducts, setIncludedProducts] = useState<Course[]>([]);
const [productNotFound, setProductNotFound] = useState(false);
const [loginProviders, setLoginProviders] = useState<
RuntimeLoginProvider[]
>([]);
Expand Down Expand Up @@ -60,10 +64,7 @@ export default function ProductCheckout() {
if (response.includedProducts) {
setIncludedProducts([...response.includedProducts]);
} else {
toast({
title: TOAST_TITLE_ERROR,
description: "Course not found",
});
setProductNotFound(true);
}
} catch (err: any) {
toast({
Expand All @@ -77,7 +78,7 @@ export default function ProductCheckout() {
const getProduct = useCallback(async () => {
const query = `
query ($id: String!) {
course: getCourse(id: $id) {
course: getCourse(id: $id, asGuest: true) {
courseId
title
slug
Expand Down Expand Up @@ -126,10 +127,7 @@ export default function ProductCheckout() {
});
setPaymentPlans([...response.course.paymentPlans]);
} else {
toast({
title: TOAST_TITLE_ERROR,
description: "Course not found",
});
setProductNotFound(true);
}
setLoginProviders(response.loginProviders || []);
} catch (err: any) {
Expand Down Expand Up @@ -195,10 +193,7 @@ export default function ProductCheckout() {
});
setPaymentPlans([...response.community.paymentPlans]);
} else {
toast({
title: TOAST_TITLE_ERROR,
description: "Community not found",
});
setProductNotFound(true);
}
setLoginProviders(response.loginProviders || []);
} catch (err: any) {
Expand Down Expand Up @@ -226,18 +221,27 @@ export default function ProductCheckout() {
}
}, [paymentPlans, getIncludedProducts]);

if (productNotFound) {
notFound();
}

if (!product) {
return null;
}

return (
<Checkout
product={product}
paymentPlans={paymentPlans}
includedProducts={includedProducts}
loginProviders={loginProviders}
type={entityType as MembershipEntityType | undefined}
id={entityId as string | undefined}
/>
<>
<Header1 theme={theme.theme} className="mb-8">
Checkout
</Header1>
<Checkout
product={product}
paymentPlans={paymentPlans}
includedProducts={includedProducts}
loginProviders={loginProviders}
type={entityType as MembershipEntityType | undefined}
id={entityId as string | undefined}
/>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,17 @@ describe("course helpers formatCourse", () => {
formatted.groups[0].lessons.map((lesson) => lesson.lessonId),
).toEqual(["lesson-3", "lesson-2"]);
});

it("throws item_not_found instead of reading properties from a null course", () => {
expect(() => formatCourse(null as any)).toThrow("Item not found");
});

it("maps backend effective preview state", () => {
const formatted = formatCourse({
...makeCourse(),
isPreview: true,
});

expect(formatted.isPreview).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
jest.mock("next/navigation", () => ({
usePathname: () => "/course/test-course/course-1",
useSearchParams: () => new URLSearchParams(),
}));

jest.mock("next/link", () => {
return ({ children }: { children: React.ReactNode }) => children;
});

jest.mock("@components/contexts", () => ({
ProfileContext: { Provider: ({ children }: any) => children },
SiteInfoContext: { Provider: ({ children }: any) => children },
ThemeContext: { Provider: ({ children }: any) => children },
}));
jest.mock("@components/contexts", () => {
const React = require("react");

return {
ProfileContext: React.createContext({
profile: {
userId: "user-1",
purchases: [],
},
}),
SiteInfoContext: React.createContext({
title: "Test Site",
logo: undefined,
hideCourseLitBranding: true,
}),
ThemeContext: React.createContext({
theme: {},
}),
};
});

jest.mock("@components/ui/sidebar", () => ({
Sidebar: ({ children }: any) => children,
Expand All @@ -25,6 +41,7 @@ jest.mock("@components/ui/sidebar", () => ({
SidebarMenuItem: ({ children }: any) => children,
SidebarProvider: ({ children }: any) => children,
SidebarTrigger: () => null,
useSidebar: () => ({ openMobile: false }),
}));

jest.mock("@components/ui/tooltip", () => ({
Expand Down Expand Up @@ -57,6 +74,7 @@ jest.mock("@courselit/icons", () => ({
}));

jest.mock("lucide-react", () => ({
BookOpen: () => null,
ChevronRight: () => null,
Clock: () => null,
LogOutIcon: () => null,
Expand All @@ -73,9 +91,10 @@ jest.mock("@ui-lib/utils", () => ({
}));

import { Constants, Profile } from "@courselit/common-models";
import { generateSideBarItems } from "../layout-with-sidebar";
import ProductPage, { generateSideBarItems } from "../layout-with-sidebar";
import { CourseFrontend } from "../helpers";
import constants from "@/config/constants";
import { render, screen } from "@testing-library/react";

describe("generateSideBarItems", () => {
const originalDateNow = Date.now;
Expand Down Expand Up @@ -725,4 +744,83 @@ describe("generateSideBarItems", () => {
"Text 3",
]);
});

it("shows dripped enrollment-gated lessons as unlocked in preview mode", () => {
const course = {
title: "Course",
description: "",
featuredImage: undefined,
updatedAt: new Date().toISOString(),
creatorId: "creator-1",
slug: "test-course",
cost: 0,
courseId: "course-1",
tags: [],
paymentPlans: [],
defaultPaymentPlan: "",
firstLesson: "lesson-1",
isPreview: true,
groups: [
{
id: "group-1",
name: "Admin Section",
lessons: [
{
lessonId: "lesson-1",
title: "Gated Lesson",
requiresEnrollment: true,
},
],
drip: {
status: true,
type: Constants.dripType[0].split("-")[0].toUpperCase(),
delayInMillis: 2,
},
},
],
} as unknown as CourseFrontend;

const profile = {
userId: "admin-1",
purchases: [],
} as unknown as Profile;

const items = generateSideBarItems(
course,
profile,
"/course/test-course/course-1",
);

expect(items[1].badge).toBeUndefined();
expect(items[1].items?.[0].icon).toBeUndefined();
});
});

describe("Course viewer layout", () => {
it("renders the preview badge in the viewer header when preview mode is active", () => {
const course = {
title: "Course",
description: "",
featuredImage: undefined,
updatedAt: new Date().toISOString(),
creatorId: "creator-1",
slug: "test-course",
cost: 0,
courseId: "course-1",
tags: [],
paymentPlans: [],
defaultPaymentPlan: "",
firstLesson: "lesson-1",
isPreview: true,
groups: [],
} as unknown as CourseFrontend;

render(
<ProductPage product={course}>
<div>Course body</div>
</ProductPage>,
);

expect(screen.getByText("Preview")).toBeInTheDocument();
});
});
Loading
Loading