From 728a681483715df9b059efca2efc02ed2504ed55 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Wed, 29 Jul 2026 12:20:24 -0400 Subject: [PATCH 1/6] send anonymous users directly to checkout --- .../ProductPages/useCourseEnrollment.test.tsx | 57 +++++++++++++++++++ .../ProductPages/useCourseEnrollment.ts | 10 ++++ .../useProgramEnrollment.test.tsx | 48 ++++++++++++++++ .../ProductPages/useProgramEnrollment.ts | 14 +++-- 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx index 44061a9c97..511ad2d17e 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx @@ -548,6 +548,63 @@ describe("useCourseEnrollment — actions", () => { expect(onRequireSignup).toHaveBeenCalledWith(anchorButton) }) + test("unauthenticated paid click -> basket clear + add (no signup)", async () => { + const product = makeProduct() + const run = makeRun({ + is_enrollable: true, + is_upgradable: true, + is_archived: false, + enrollment_modes: [makeMode({ requires_payment: true })], + products: [product], + }) + const course = makeCourse({ next_run_id: run.id, courseruns: [run] }) + + setMockResponse.get( + urls.userMe.get(), + makeUser({ is_authenticated: false }), + ) + const clearUrl = mitxUrls.baskets.clear() + const basketUrl = mitxUrls.baskets.createFromProduct(product.id) + setMockResponse.delete(clearUrl, undefined) + setMockResponse.post(basketUrl, { id: 1, items: [] }) + + const onRequireSignup = jest.fn() + + const { result } = renderHook( + () => + useCourseEnrollment(course, run, { + tracking: { placement: "infobox" }, + onRequireSignup, + }), + { wrapper }, + ) + + await waitFor(() => expect(result.current.isStatusLoading).toBe(false)) + + const state = result.current.state + expect(state.status).toBe("options") + if (state.status !== "options") return + + const paidOption = state.options.find((o) => o.kind === "paid") + expect(paidOption).toBeDefined() + + paidOption!.onClick!({ + currentTarget: document.createElement("button"), + } as React.MouseEvent) + + // Anonymous paid checkout hands off to the MITx Online basket rather than + // prompting signup first. + await waitFor(() => + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "delete", url: clearUrl }), + ), + ) + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "post", url: basketUrl }), + ) + expect(onRequireSignup).not.toHaveBeenCalled() + }) + test("fires PostHog enroll_cta_clicked on paid action click", async () => { const product = makeProduct() const run = makeRun({ diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts index 196244e8c7..75cf9d0892 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts @@ -96,12 +96,22 @@ export const useCourseEnrollment = ( } trackStartEnrollment(course.title) if (kind === "paid") { + // Paid checkout runs for anonymous users too. The basket and cart live + // on MITx Online, which supports anonymous baskets, so we hand off + // directly to checkout and defer account creation to the MITx Online + // flow (Review → Account → Verify → Payment). const product = selectedRun?.products?.[0] if (product) { trackBeginCheckout(course.title) replaceBasketItem.mutate(product.id) } } else if (kind === "free") { + // Free enrollment requires an account — there is no anonymous audit + // enrollment — so unauthenticated users are routed to signup first. + if (!me.data?.is_authenticated) { + opts?.onRequireSignup?.(e.currentTarget) + return + } if (selectedRun) { createEnrollment.mutate( { run_id: selectedRun.id }, diff --git a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx index 1fa826f4f9..6a9904afdb 100644 --- a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx @@ -393,4 +393,52 @@ describe("useProgramEnrollment — actions", () => { expect.objectContaining({ method: "post", url: basketUrl }), ) }) + + test("unauthenticated paid action -> hands off to basket (no signup)", async () => { + setMockResponse.get( + urls.userMe.get(), + makeUser({ is_authenticated: false }), + ) + + const product = makeProduct({ price: "100" }) + const program = makeProgram({ + enrollment_modes: [makeMode({ requires_payment: true })], + products: [product], + }) + const clearUrl = mitxUrls.baskets.clear() + const basketUrl = mitxUrls.baskets.createFromProduct(product.id) + setMockResponse.delete(clearUrl, undefined) + setMockResponse.post(basketUrl, { id: 1, items: [] }) + + const onRequireSignup = jest.fn() + + const { result } = renderHook( + () => + useProgramEnrollment(program, { + tracking: { placement: "infobox" }, + onRequireSignup, + }), + { wrapper }, + ) + + await waitFor(() => expect(result.current.isStatusLoading).toBe(false)) + + const state = result.current.state + if (state.status !== "options") throw new Error("expected options") + const paid = state.options.find((o) => o.kind === "paid")! + + paid.onClick!(fakeClickEvent()) + + // Anonymous paid checkout hands off to the MITx Online basket rather than + // prompting signup first. + await waitFor(() => + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "delete", url: clearUrl }), + ), + ) + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "post", url: basketUrl }), + ) + expect(onRequireSignup).not.toHaveBeenCalled() + }) }) diff --git a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts index d4a18a9cda..7090758e41 100644 --- a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts @@ -74,16 +74,22 @@ export const useProgramEnrollment = ( resourceType: "program", readableId: program.readable_id, }) - if (!me.data?.is_authenticated) { - opts?.onRequireSignup?.(e.currentTarget) - return - } if (kind === "paid") { + // Paid checkout runs for anonymous users too. The basket and cart live + // on MITx Online, which supports anonymous baskets, so we hand off + // directly to checkout and defer account creation to the MITx Online + // flow (Review → Account → Verify → Payment). const product = program.products[0] if (product) { replaceBasketItem.mutate(product.id) } } else if (kind === "free") { + // Free enrollment requires an account — there is no anonymous program + // enrollment — so unauthenticated users are routed to signup first. + if (!me.data?.is_authenticated) { + opts?.onRequireSignup?.(e.currentTarget) + return + } createProgramEnrollment.mutate( { V3ProgramEnrollmentRequestRequest: { program_id: program.id } }, { From e8c93906f3ae1dfd8ab06b7bde62bd94c1852dc8 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Fri, 31 Jul 2026 15:46:28 -0400 Subject: [PATCH 2/6] enable anonymous checkout --- .../api/src/mitxonline/hooks/baskets/index.ts | 25 ++++++++--- .../ProductPages/useCourseEnrollment.ts | 12 ++--- .../mitxonline/useReplaceBasketItem.test.tsx | 45 +++++++++++++++++++ .../common/mitxonline/useReplaceBasketItem.ts | 22 +++++++-- 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/frontends/api/src/mitxonline/hooks/baskets/index.ts b/frontends/api/src/mitxonline/hooks/baskets/index.ts index a603caf661..e1d8b675bd 100644 --- a/frontends/api/src/mitxonline/hooks/baskets/index.ts +++ b/frontends/api/src/mitxonline/hooks/baskets/index.ts @@ -1,6 +1,7 @@ import { basketQueries } from "./queries" import { useMutation, useQueryClient } from "@tanstack/react-query" import { basketsApi } from "../../clients" +import { useUserIsAuthenticated } from "../../../hooks/user" import type { BasketWithProduct } from "@mitodl/mitxonline-api-axios/v2" /** @@ -9,6 +10,7 @@ import type { BasketWithProduct } from "@mitodl/mitxonline-api-axios/v2" */ const useAddToBasket = () => { const queryClient = useQueryClient() + const isAuthenticated = useUserIsAuthenticated() return useMutation({ mutationFn: async (productId: number): Promise => { const response = await basketsApi.basketsCreateFromProductCreate({ @@ -17,10 +19,16 @@ const useAddToBasket = () => { return response.data }, onSuccess: async () => { - // Invalidate checkout query to ensure fresh data - queryClient.invalidateQueries({ - queryKey: basketQueries.basketState().queryKey, - }) + // Invalidate checkout query to ensure fresh data - but only once + // authenticated. basketState hits the checkout-payload endpoint, which + // creates a real order against the basket's purchaser; an anonymous + // basket has no purchaser, so this has to wait until after the + // anonymous-checkout conversion step, not fire on every cart add. + if (isAuthenticated) { + queryClient.invalidateQueries({ + queryKey: basketQueries.basketState().queryKey, + }) + } }, }) } @@ -30,14 +38,17 @@ const useAddToBasket = () => { */ const useClearBasket = () => { const queryClient = useQueryClient() + const isAuthenticated = useUserIsAuthenticated() return useMutation({ mutationFn: async (): Promise => { await basketsApi.basketsClearDestroy() }, onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: basketQueries.basketState().queryKey, - }) + if (isAuthenticated) { + queryClient.invalidateQueries({ + queryKey: basketQueries.basketState().queryKey, + }) + } }, }) } diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts index 75cf9d0892..6d41f47b01 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts @@ -90,7 +90,10 @@ export const useCourseEnrollment = ( resourceType: "course", readableId: course.readable_id, }) - if (!me.data?.is_authenticated) { + // Only the free/audit track requires an account - paid checkout hands + // off to the (anonymous-capable) MITx Online basket regardless of auth + // state, so the signup gate only applies to "free" clicks. + if (kind === "free" && !me.data?.is_authenticated) { opts?.onRequireSignup?.(e.currentTarget) return } @@ -107,11 +110,8 @@ export const useCourseEnrollment = ( } } else if (kind === "free") { // Free enrollment requires an account — there is no anonymous audit - // enrollment — so unauthenticated users are routed to signup first. - if (!me.data?.is_authenticated) { - opts?.onRequireSignup?.(e.currentTarget) - return - } + // enrollment — so unauthenticated users are routed to signup first + // (handled by the guard above). if (selectedRun) { createEnrollment.mutate( { run_id: selectedRun.id }, diff --git a/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx b/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx index 18abf6543b..efdc1a0f78 100644 --- a/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx +++ b/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx @@ -100,4 +100,49 @@ describe("useReplaceBasketItem", () => { expect(mutateAsync).not.toHaveBeenCalled() expect(assign).not.toHaveBeenCalled() }) + + test("appends anonymous_basket_id to the redirect url for an anonymous basket (sync path)", () => { + const assign = jest.mocked(window.location.assign) + mutate.mockImplementationOnce( + (_productId: number, opts?: { onSuccess?: (b: unknown) => void }) => + opts?.onSuccess?.({ id: 9, anonymous_id: "abc-123" }), + ) + const { result } = renderHook(() => useReplaceBasketItem()) + + act(() => { + result.current.mutate(42) + }) + + const calledUrl = new URL(assign.mock.calls[0][0]) + expect(calledUrl.searchParams.get("anonymous_basket_id")).toBe("abc-123") + }) + + test("appends anonymous_basket_id to the redirect url for an anonymous basket (async path)", async () => { + const assign = jest.mocked(window.location.assign) + mutateAsync.mockResolvedValueOnce({ id: 9, anonymous_id: "abc-123" }) + const { result } = renderHook(() => useReplaceBasketItem()) + + await act(async () => { + await result.current.mutateAsync(42) + }) + + const calledUrl = new URL(assign.mock.calls[0][0]) + expect(calledUrl.searchParams.get("anonymous_basket_id")).toBe("abc-123") + }) + + test("does not append anonymous_basket_id when the basket belongs to a real user", () => { + const assign = jest.mocked(window.location.assign) + mutate.mockImplementationOnce( + (_productId: number, opts?: { onSuccess?: (b: unknown) => void }) => + opts?.onSuccess?.({ id: 9, user: 1, anonymous_id: null }), + ) + const { result } = renderHook(() => useReplaceBasketItem()) + + act(() => { + result.current.mutate(42) + }) + + const calledUrl = new URL(assign.mock.calls[0][0]) + expect(calledUrl.searchParams.has("anonymous_basket_id")).toBe(false) + }) }) diff --git a/frontends/main/src/common/mitxonline/useReplaceBasketItem.ts b/frontends/main/src/common/mitxonline/useReplaceBasketItem.ts index 04ea858966..a232f9f9de 100644 --- a/frontends/main/src/common/mitxonline/useReplaceBasketItem.ts +++ b/frontends/main/src/common/mitxonline/useReplaceBasketItem.ts @@ -1,13 +1,27 @@ import { useAddToBasket, useClearBasket } from "api/mitxonline-hooks/baskets" import { mitxonlineLegacyUrl } from "@/common/mitxonline" +import type { BasketWithProduct } from "@mitodl/mitxonline-api-axios/v2" -const cartUrl = () => mitxonlineLegacyUrl("/cart/") +// Django's session cookie (which carries an anonymous basket's id) is +// host-only and can't be widened to a shared mit.edu-scoped cookie - too +// many departments doing that already causes "cookie too big" errors +// institution-wide. So the id is handed off explicitly through the redirect +// URL instead of relying on the browser to carry a cross-domain cookie from +// this (Learn-proxied) API call to mitxonline's own /cart/ page. +const cartUrl = (anonymousBasketId?: string | null) => { + const url = new URL(mitxonlineLegacyUrl("/cart/")) + if (anonymousBasketId) { + url.searchParams.set("anonymous_basket_id", anonymousBasketId) + } + return url.toString() +} const useReplaceBasketItem = () => { const addToBasket = useAddToBasket() const clearBasket = useClearBasket() - const redirect = () => window.location.assign(cartUrl()) + const redirect = (basket?: BasketWithProduct) => + window.location.assign(cartUrl(basket?.anonymous_id)) const mutate = (productId: number) => { addToBasket.reset() @@ -22,8 +36,8 @@ const useReplaceBasketItem = () => { const mutateAsync = async (productId: number) => { addToBasket.reset() await clearBasket.mutateAsync() - await addToBasket.mutateAsync(productId) - redirect() + const basket = await addToBasket.mutateAsync(productId) + redirect(basket) } return { From d4505bda6289e67a625dc3b802e2160e47f1cd83 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Fri, 31 Jul 2026 15:55:55 -0400 Subject: [PATCH 3/6] add feature flagged anonymous checkout --- .../ProductPages/useCourseEnrollment.test.tsx | 55 ++++++++++++++++++- .../ProductPages/useCourseEnrollment.ts | 18 ++++-- .../useProgramEnrollment.test.tsx | 48 +++++++++++++++- .../ProductPages/useProgramEnrollment.ts | 24 ++++++-- frontends/main/src/common/feature_flags.ts | 1 + 5 files changed, 133 insertions(+), 13 deletions(-) diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx index 511ad2d17e..96897a9da1 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx @@ -20,7 +20,7 @@ import type { CourseRunV2, CourseWithCourseRunsSerializerV2, } from "@mitodl/mitxonline-api-axios/v2" -import { usePostHog } from "posthog-js/react" +import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react" import { PostHogEvents } from "@/common/constants" import { trackCourseEnrolled, @@ -37,12 +37,15 @@ jest.mock("@/common/analytics/gtm", () => ({ jest.mock("posthog-js/react", () => ({ ...jest.requireActual("posthog-js/react"), usePostHog: jest.fn(), + useFeatureFlagEnabled: jest.fn(), })) const mockCapture = jest.fn() jest.mocked(usePostHog).mockReturnValue( // @ts-expect-error Not mocking all of posthog { capture: mockCapture }, ) +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) +mockedUseFeatureFlagEnabled.mockReturnValue(true) const mockPush = jest.fn() jest.mock("next-nprogress-bar", () => ({ @@ -342,6 +345,7 @@ describe("useCourseEnrollment — actions", () => { beforeEach(() => { jest.clearAllMocks() + mockedUseFeatureFlagEnabled.mockReturnValue(true) process.env.NEXT_PUBLIC_POSTHOG_API_KEY = "test-key" setMockResponse.get(urls.userMe.get(), makeUser({ is_authenticated: true })) setMockResponse.get(mitxUrls.enrollment.enrollmentsListV3(), []) @@ -605,6 +609,55 @@ describe("useCourseEnrollment — actions", () => { expect(onRequireSignup).not.toHaveBeenCalled() }) + test("unauthenticated paid click requires signup when the anonymous-checkout flag is off", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + + const product = makeProduct() + const run = makeRun({ + is_enrollable: true, + is_upgradable: true, + is_archived: false, + enrollment_modes: [makeMode({ requires_payment: true })], + products: [product], + }) + const course = makeCourse({ next_run_id: run.id, courseruns: [run] }) + + setMockResponse.get( + urls.userMe.get(), + makeUser({ is_authenticated: false }), + ) + + const onRequireSignup = jest.fn() + + const { result } = renderHook( + () => + useCourseEnrollment(course, run, { + tracking: { placement: "infobox" }, + onRequireSignup, + }), + { wrapper }, + ) + + await waitFor(() => expect(result.current.isStatusLoading).toBe(false)) + + const state = result.current.state + expect(state.status).toBe("options") + if (state.status !== "options") return + + const paidOption = state.options.find((o) => o.kind === "paid") + expect(paidOption).toBeDefined() + + const anchorButton = document.createElement("button") + paidOption!.onClick!({ + currentTarget: anchorButton, + } as React.MouseEvent) + + expect(onRequireSignup).toHaveBeenCalledWith(anchorButton) + expect(makeRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ method: "delete" }), + ) + }) + test("fires PostHog enroll_cta_clicked on paid action click", async () => { const product = makeProduct() const run = makeRun({ diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts index 6d41f47b01..83e01ae6bb 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts @@ -8,7 +8,8 @@ import { useCreateEnrollment } from "api/mitxonline-hooks/enrollment" import { useReplaceBasketItem } from "@/common/mitxonline/useReplaceBasketItem" import { enrollmentAlertSuccessUrl } from "@/common/mitxonline" import { useRouter } from "next-nprogress-bar" -import { usePostHog } from "posthog-js/react" +import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" import { trackCourseEnrolled, trackStartEnrollment, @@ -63,6 +64,9 @@ export const useCourseEnrollment = ( const createEnrollment = useCreateEnrollment() const router = useRouter() const posthog = usePostHog() + const anonymousCheckoutEnabled = useFeatureFlagEnabled( + FeatureFlags.AnonymousCheckout, + ) const isEnrolledInSelected = selectedRun !== undefined && enrolledRunIds.includes(selectedRun.id) @@ -90,10 +94,14 @@ export const useCourseEnrollment = ( resourceType: "course", readableId: course.readable_id, }) - // Only the free/audit track requires an account - paid checkout hands - // off to the (anonymous-capable) MITx Online basket regardless of auth - // state, so the signup gate only applies to "free" clicks. - if (kind === "free" && !me.data?.is_authenticated) { + // The free/audit track always requires an account. The paid track + // hands off to the (anonymous-capable) MITx Online basket regardless of + // auth state, but only once the anonymous-checkout flag is on - until + // then, anonymous paid clicks fall back to the signup gate too. + if ( + !me.data?.is_authenticated && + (kind === "free" || !anonymousCheckoutEnabled) + ) { opts?.onRequireSignup?.(e.currentTarget) return } diff --git a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx index 6a9904afdb..bb20837a5d 100644 --- a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx @@ -14,7 +14,7 @@ import { } from "api/mitxonline-test-utils" import { useProgramEnrollment } from "./useProgramEnrollment" import { programView } from "@/common/urls" -import { usePostHog } from "posthog-js/react" +import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react" import { PostHogEvents } from "@/common/constants" import { trackProgramEnrolled } from "@/common/analytics/gtm" import { PlatformEnum } from "api" @@ -26,12 +26,15 @@ jest.mock("@/common/analytics/gtm", () => ({ jest.mock("posthog-js/react", () => ({ ...jest.requireActual("posthog-js/react"), usePostHog: jest.fn(), + useFeatureFlagEnabled: jest.fn(), })) const mockCapture = jest.fn() jest.mocked(usePostHog).mockReturnValue( // @ts-expect-error Not mocking all of posthog { capture: mockCapture }, ) +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) +mockedUseFeatureFlagEnabled.mockReturnValue(true) const mockPush = jest.fn() jest.mock("next-nprogress-bar", () => ({ @@ -220,6 +223,7 @@ describe("useProgramEnrollment — actions", () => { beforeEach(() => { jest.clearAllMocks() + mockedUseFeatureFlagEnabled.mockReturnValue(true) process.env.NEXT_PUBLIC_POSTHOG_API_KEY = "test-key" }) @@ -441,4 +445,46 @@ describe("useProgramEnrollment — actions", () => { ) expect(onRequireSignup).not.toHaveBeenCalled() }) + + test("unauthenticated paid action requires signup when the anonymous-checkout flag is off", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + + setMockResponse.get( + urls.userMe.get(), + makeUser({ is_authenticated: false }), + ) + + const product = makeProduct({ price: "100" }) + const program = makeProgram({ + enrollment_modes: [makeMode({ requires_payment: true })], + products: [product], + }) + + const onRequireSignup = jest.fn() + + const { result } = renderHook( + () => + useProgramEnrollment(program, { + tracking: { placement: "infobox" }, + onRequireSignup, + }), + { wrapper }, + ) + + await waitFor(() => expect(result.current.isStatusLoading).toBe(false)) + + const state = result.current.state + if (state.status !== "options") throw new Error("expected options") + const paid = state.options.find((o) => o.kind === "paid")! + + const anchorButton = document.createElement("button") + paid.onClick!({ + currentTarget: anchorButton, + } as React.MouseEvent) + + expect(onRequireSignup).toHaveBeenCalledWith(anchorButton) + expect(makeRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ method: "delete" }), + ) + }) }) diff --git a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts index 7090758e41..aabd2b6470 100644 --- a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts @@ -5,7 +5,8 @@ import { useCreateProgramEnrollment } from "api/mitxonline-hooks/enrollment" import { useReplaceBasketItem } from "@/common/mitxonline/useReplaceBasketItem" import { enrollmentAlertSuccessUrl } from "@/common/mitxonline" import { useRouter } from "next-nprogress-bar" -import { usePostHog } from "posthog-js/react" +import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" import { trackProgramEnrolled } from "@/common/analytics/gtm" import { programView } from "@/common/urls" import { fireEnrollCta, type EnrollCtaPlacement } from "./enrollAnalytics" @@ -51,6 +52,9 @@ export const useProgramEnrollment = ( const createProgramEnrollment = useCreateProgramEnrollment() const router = useRouter() const posthog = usePostHog() + const anonymousCheckoutEnabled = useFeatureFlagEnabled( + FeatureFlags.AnonymousCheckout, + ) const offering = getProgramOffering(program) @@ -74,6 +78,17 @@ export const useProgramEnrollment = ( resourceType: "program", readableId: program.readable_id, }) + // The free track always requires an account. The paid track hands off + // to the (anonymous-capable) MITx Online basket regardless of auth + // state, but only once the anonymous-checkout flag is on - until then, + // anonymous paid clicks fall back to the signup gate too. + if ( + !me.data?.is_authenticated && + (kind === "free" || !anonymousCheckoutEnabled) + ) { + opts?.onRequireSignup?.(e.currentTarget) + return + } if (kind === "paid") { // Paid checkout runs for anonymous users too. The basket and cart live // on MITx Online, which supports anonymous baskets, so we hand off @@ -85,11 +100,8 @@ export const useProgramEnrollment = ( } } else if (kind === "free") { // Free enrollment requires an account — there is no anonymous program - // enrollment — so unauthenticated users are routed to signup first. - if (!me.data?.is_authenticated) { - opts?.onRequireSignup?.(e.currentTarget) - return - } + // enrollment — so unauthenticated users are routed to signup first + // (handled by the guard above). createProgramEnrollment.mutate( { V3ProgramEnrollmentRequestRequest: { program_id: program.id } }, { diff --git a/frontends/main/src/common/feature_flags.ts b/frontends/main/src/common/feature_flags.ts index dc8169ab15..2a6756a055 100644 --- a/frontends/main/src/common/feature_flags.ts +++ b/frontends/main/src/common/feature_flags.ts @@ -16,6 +16,7 @@ export enum FeatureFlags { B2BAnalyticsDashboard = "b2b-analytics-dashboard", Arithmix = "arithmix", Hacksnack = "hacksnack", + AnonymousCheckout = "anonymous-checkout", } /** From 21db6eab503a82dac7ca742431f6b443e850cdaf Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Fri, 31 Jul 2026 16:10:28 -0400 Subject: [PATCH 4/6] fix tests --- .../EnrolledCourseCard.test.tsx | 17 ++++++++++++++++- .../HomeEnrollmentsDisplay.test.tsx | 12 ++++++++++++ .../ProgramAsCourseCard.test.tsx | 15 ++++++++++++++- .../ProgramEnrollmentDisplay.test.tsx | 16 +++++++++++++++- .../UnenrolledCourseCard.test.tsx | 17 ++++++++++++++++- .../CourseEnrollmentDialog.test.tsx | 16 +++++++++++++++- 6 files changed, 88 insertions(+), 5 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx index 0f829c1d10..60166521e1 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx @@ -10,7 +10,11 @@ import { } from "@/test-utils" import * as mitxonline from "api/mitxonline-test-utils" import { mitxonlineLegacyUrl } from "@/common/mitxonline" -import { makeRequest } from "api/test-utils" +import { + makeRequest, + urls as learnUrls, + factories as learnFactories, +} from "api/test-utils" import { faker } from "@faker-js/faker/locale/en" import moment from "moment" import { EnrolledCourseCard } from "./EnrolledCourseCard" @@ -20,6 +24,17 @@ const EnrollmentMode = { Verified: "verified", } as const +// useAddToBasket/useClearBasket check Learn's own auth state (separate from +// mitxonline's) before invalidating the checkout-payload query. File-scoped +// (not per-describe) since several describe blocks below each have their own +// tests that don't all route through setupUserApis. +beforeEach(() => { + setMockResponse.get( + learnUrls.userMe.get(), + learnFactories.user.user({ is_authenticated: true }), + ) +}) + const setupUserApis = ( overrides?: Parameters[0], ) => { diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx index 3cc426c761..1eb52c27e3 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx @@ -26,6 +26,7 @@ import { } from "@/test-utils" import { HomeEnrollmentsDisplay } from "./HomeEnrollmentsDisplay" import * as mitxonline from "api/mitxonline-test-utils" +import { urls as learnUrls, factories as learnFactories } from "api/test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" import { setupEnrollments } from "./test-utils" import { faker } from "@faker-js/faker/locale/en" @@ -38,6 +39,17 @@ const mockedUseFeatureFlagEnabled = jest describe("HomeEnrollmentsDisplay", () => { setupLocationMock() + // useAddToBasket/useClearBasket check Learn's own auth state (separate + // from mitxonline's) before invalidating the checkout-payload query. Set at + // the describe level since many tests here mock the rest of the user/ + // enrollment APIs inline rather than through setupApis below. + beforeEach(() => { + setMockResponse.get( + learnUrls.userMe.get(), + learnFactories.user.user({ is_authenticated: true }), + ) + }) + const setupApis = (includeExpired: boolean = true) => { const mitxOnlineUser = mitxonline.factories.user.user() setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx index 13f063f6fa..6646b0d366 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -7,7 +7,11 @@ import { user, within, } from "@/test-utils" -import { makeRequest } from "api/test-utils" +import { + makeRequest, + urls as learnUrls, + factories as learnFactories, +} from "api/test-utils" import * as mitxonline from "api/mitxonline-test-utils" import { ProgramAsCourseCard } from "./ProgramAsCourseCard" import { waitFor } from "@testing-library/react" @@ -22,6 +26,15 @@ jest.mock("posthog-js/react") describe("ProgramAsCourseCard", () => { setupLocationMock() + // useAddToBasket/useClearBasket check Learn's own auth state (separate + // from mitxonline's) before invalidating the checkout-payload query. + beforeEach(() => { + setMockResponse.get( + learnUrls.userMe.get(), + learnFactories.user.user({ is_authenticated: true }), + ) + }) + /** * Creates a ProgramAsCourseCard data set with: * - A program with two module courses linked via req_tree diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx index b60b41b513..dc84ddb308 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx @@ -27,7 +27,11 @@ import { } from "@/test-utils" import { ProgramEnrollmentDisplay } from "./ProgramEnrollmentDisplay" import * as mitxonline from "api/mitxonline-test-utils" -import { makeRequest } from "api/test-utils" +import { + makeRequest, + urls as learnUrls, + factories as learnFactories, +} from "api/test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" import { faker } from "@faker-js/faker/locale/en" import invariant from "tiny-invariant" @@ -39,6 +43,16 @@ const mockedUseFeatureFlagEnabled = jest describe("ProgramEnrollmentDisplay", () => { setupLocationMock() + + // useAddToBasket/useClearBasket check Learn's own auth state (separate + // from mitxonline's) before invalidating the checkout-payload query. + beforeEach(() => { + setMockResponse.get( + learnUrls.userMe.get(), + learnFactories.user.user({ is_authenticated: true }), + ) + }) + test("Filters to single program when programId is provided", async () => { const mitxOnlineUser = mitxonline.factories.user.user() setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx index ad6547b9fe..457a305891 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx @@ -9,7 +9,11 @@ import { within, } from "@/test-utils" import * as mitxonline from "api/mitxonline-test-utils" -import { makeRequest } from "api/test-utils" +import { + makeRequest, + urls as learnUrls, + factories as learnFactories, +} from "api/test-utils" import { faker } from "@faker-js/faker/locale/en" import moment from "moment" import { cartesianProduct } from "ol-test-utilities" @@ -25,6 +29,17 @@ const mitxOnlineCourse = mitxonline.factories.courses.course const mitxUser = mitxonline.factories.user.user +// useAddToBasket/useClearBasket check Learn's own auth state (separate from +// mitxonline's) before invalidating the checkout-payload query. File-scoped +// (not per-describe) since several describe blocks below each have their own +// tests that don't all route through setupUserApis. +beforeEach(() => { + setMockResponse.get( + learnUrls.userMe.get(), + learnFactories.user.user({ is_authenticated: true }), + ) +}) + const setupUserApis = (overrides?: Parameters[0]) => { const userData = mitxonline.factories.user.user({ is_staff: false, diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx index 8e455edda4..e682e98ffa 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx @@ -7,7 +7,12 @@ import { user, setupLocationMock, } from "@/test-utils" -import { makeRequest, setMockResponse } from "api/test-utils" +import { + makeRequest, + setMockResponse, + urls as learnUrls, + factories as learnFactories, +} from "api/test-utils" import { urls as mitxUrls, factories as mitxFactories, @@ -62,6 +67,15 @@ describe("CourseEnrollmentDialog", () => { setupLocationMock() + // useAddToBasket/useClearBasket check Learn's own auth state (separate + // from mitxonline's) before invalidating the checkout-payload query. + beforeEach(() => { + setMockResponse.get( + learnUrls.userMe.get(), + learnFactories.user.user({ is_authenticated: true }), + ) + }) + describe("Course run dropdown", () => { test("Shows one entry for each enrollable course run", async () => { const run1 = enrollableRun() From dd01adf8da448c17129b850541a0ade6015001c9 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Wed, 5 Aug 2026 15:04:29 -0400 Subject: [PATCH 5/6] fix test --- .../mitxonline/useReplaceBasketItem.test.tsx | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx b/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx index efdc1a0f78..7fd131b480 100644 --- a/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx +++ b/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx @@ -1,14 +1,33 @@ import { act, renderHook, setupLocationMock } from "@/test-utils" import { mitxonlineLegacyUrl } from "@/common/mitxonline" +import type { BasketWithProduct } from "@mitodl/mitxonline-api-axios/v2" import { useReplaceBasketItem } from "./useReplaceBasketItem" +const basket = ( + overrides: Partial = {}, +): BasketWithProduct => ({ + id: 7, + basket_items: [], + total_price: 0, + discounted_price: 0, + discounts: [], + ...overrides, +}) + +/** + * `useAddToBasket().mutate` hands the created basket to `onSuccess` — the hook + * relies on it to read `anonymous_id`. Shared between the default mock and the + * per-test overrides so the two cannot drift apart. + */ +type AddToBasketOpts = { onSuccess?: (basket: BasketWithProduct) => void } + const reset = jest.fn() -const mutate = jest.fn( - (_productId: number, opts?: { onSuccess?: () => void }) => - opts?.onSuccess?.(), +const mutate = jest.fn((_productId: number, opts?: AddToBasketOpts) => + opts?.onSuccess?.(basket()), ) -const mutateAsync = jest.fn().mockResolvedValue({ id: 7 }) +const mutateAsync = jest.fn().mockResolvedValue(basket()) const clearMutate = jest.fn( + // The hook's clear-basket callback takes no arguments. (_vars: undefined, opts?: { onSuccess?: () => void }) => opts?.onSuccess?.(), ) const clearMutateAsync = jest.fn().mockResolvedValue(undefined) @@ -103,9 +122,8 @@ describe("useReplaceBasketItem", () => { test("appends anonymous_basket_id to the redirect url for an anonymous basket (sync path)", () => { const assign = jest.mocked(window.location.assign) - mutate.mockImplementationOnce( - (_productId: number, opts?: { onSuccess?: (b: unknown) => void }) => - opts?.onSuccess?.({ id: 9, anonymous_id: "abc-123" }), + mutate.mockImplementationOnce((_productId, opts) => + opts?.onSuccess?.(basket({ id: 9, anonymous_id: "abc-123" })), ) const { result } = renderHook(() => useReplaceBasketItem()) @@ -119,7 +137,9 @@ describe("useReplaceBasketItem", () => { test("appends anonymous_basket_id to the redirect url for an anonymous basket (async path)", async () => { const assign = jest.mocked(window.location.assign) - mutateAsync.mockResolvedValueOnce({ id: 9, anonymous_id: "abc-123" }) + mutateAsync.mockResolvedValueOnce( + basket({ id: 9, anonymous_id: "abc-123" }), + ) const { result } = renderHook(() => useReplaceBasketItem()) await act(async () => { @@ -132,9 +152,8 @@ describe("useReplaceBasketItem", () => { test("does not append anonymous_basket_id when the basket belongs to a real user", () => { const assign = jest.mocked(window.location.assign) - mutate.mockImplementationOnce( - (_productId: number, opts?: { onSuccess?: (b: unknown) => void }) => - opts?.onSuccess?.({ id: 9, user: 1, anonymous_id: null }), + mutate.mockImplementationOnce((_productId, opts) => + opts?.onSuccess?.(basket({ id: 9, user: 1, anonymous_id: null })), ) const { result } = renderHook(() => useReplaceBasketItem()) From 5f1a0916011356d6357926a61929f4b17fea1aa7 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Wed, 5 Aug 2026 15:13:11 -0400 Subject: [PATCH 6/6] fix more typecheck problems --- .../CoursewareDisplay/OrganizationCards.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx index 11d7580ece..3d87220b96 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx @@ -89,6 +89,7 @@ describe("OrganizationCards", () => { description: "Test Description", logo: "https://example.com/logo1.png", slug: "org-test", + sso_organization_id: null, contracts: [ mitxOnlineFactories.contracts.contract({ id: 1, @@ -116,6 +117,7 @@ describe("OrganizationCards", () => { description: "Test description", logo: "https://example.com/logo.png", slug: "org-test-org", + sso_organization_id: null, contracts: [ mitxOnlineFactories.contracts.contract({ id: 1, @@ -168,6 +170,7 @@ describe("OrganizationCards", () => { description: "Test description", logo: "https://example.com/logo.png", slug: "org-test-org", + sso_organization_id: null, contracts: [contract1, contract2], }) @@ -200,6 +203,7 @@ describe("OrganizationCards", () => { description: "Test description", logo: "https://example.com/logo.png", slug: "org-test-org", + sso_organization_id: null, contracts: [ mitxOnlineFactories.contracts.contract({ id: 1, @@ -225,6 +229,7 @@ describe("OrganizationCards", () => { description: "Test description", logo: "https://example.com/logo.png", slug: "org-test-org", + sso_organization_id: null, contracts: [], // No contracts for this organization }) @@ -253,6 +258,7 @@ describe("OrganizationCards", () => { description: "Test description", logo: "https://example.com/logo1.png", slug: "org-one", + sso_organization_id: null, contracts: [ mitxOnlineFactories.contracts.contract({ id: 1, @@ -274,6 +280,7 @@ describe("OrganizationCards", () => { description: "Test description", logo: "https://example.com/logo2.png", slug: "org-two", + sso_organization_id: null, contracts: [ mitxOnlineFactories.contracts.contract({ id: 3, @@ -341,6 +348,7 @@ describe("OrganizationCards", () => { name: "Test Organization", logo: undefined, slug: "org-test-org", + sso_organization_id: null, contracts: [ mitxOnlineFactories.contracts.contract({ id: 1, @@ -376,6 +384,7 @@ describe("OrganizationCards", () => { description: "Test description", logo: "https://example.com/logo.png", slug: "org-my-company", + sso_organization_id: null, contracts: [contract], }) @@ -403,6 +412,7 @@ describe("OrganizationCards", () => { description: "Test description", logo: "https://example.com/logo.png", slug: "my-company", // No 'org-' prefix + sso_organization_id: null, contracts: [contract], }) @@ -424,6 +434,7 @@ describe("OrganizationCards", () => { description: "Test description", logo: "https://example.com/logo.png", slug: "org-test-org", + sso_organization_id: null, contracts: [], // No contracts for this organization })