From 8536a814889f788c975b672913e7c410e1e59baa Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Thu, 6 Aug 2026 17:25:37 -0400 Subject: [PATCH 1/4] Invalidate the basket checkout query unconditionally useAddToBasket/useClearBasket gated invalidateQueries on Learn's useUserIsAuthenticated. Two problems: - It reads the wrong session. The gated operation targets MITx Online through a separate APISIX route with its own OIDC cookie, so "logged out on Learn, logged in on MITx Online" would skip an invalidation that should fire. - Gating belongs on the consumer, not the invalidator. invalidateQueries defaults to refetchType: "active", and a query whose observers all set enabled: false is not active, so an anonymous user never refetches anyway. basketState also sets gcTime: 0, so with no observers there is nothing cached to invalidate. The guard could not prevent the 403 it was written for either: a consumer that forgot `enabled` takes one on mount regardless, and suppressing only the refetch turns a loud failure into a quiet stale one. The stated rationale was also wrong -- the checkout-payload endpoint kept IsAuthenticated, so an anonymous call 403s rather than creating an order against a purchaser-less basket. Drops the six beforeEach blocks that existed only to feed the guard a Learn user. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/src/mitxonline/hooks/baskets/index.ts | 25 ++++++------------- .../EnrolledCourseCard.test.tsx | 17 +------------ .../HomeEnrollmentsDisplay.test.tsx | 12 --------- .../ProgramAsCourseCard.test.tsx | 15 +---------- .../ProgramEnrollmentDisplay.test.tsx | 15 +---------- .../UnenrolledCourseCard.test.tsx | 17 +------------ .../CourseEnrollmentDialog.test.tsx | 16 +----------- 7 files changed, 12 insertions(+), 105 deletions(-) diff --git a/frontends/api/src/mitxonline/hooks/baskets/index.ts b/frontends/api/src/mitxonline/hooks/baskets/index.ts index e1d8b675bd..a603caf661 100644 --- a/frontends/api/src/mitxonline/hooks/baskets/index.ts +++ b/frontends/api/src/mitxonline/hooks/baskets/index.ts @@ -1,7 +1,6 @@ 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" /** @@ -10,7 +9,6 @@ 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({ @@ -19,16 +17,10 @@ const useAddToBasket = () => { return response.data }, onSuccess: async () => { - // 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, - }) - } + // Invalidate checkout query to ensure fresh data + queryClient.invalidateQueries({ + queryKey: basketQueries.basketState().queryKey, + }) }, }) } @@ -38,17 +30,14 @@ const useAddToBasket = () => { */ const useClearBasket = () => { const queryClient = useQueryClient() - const isAuthenticated = useUserIsAuthenticated() return useMutation({ mutationFn: async (): Promise => { await basketsApi.basketsClearDestroy() }, onSuccess: () => { - if (isAuthenticated) { - queryClient.invalidateQueries({ - queryKey: basketQueries.basketState().queryKey, - }) - } + queryClient.invalidateQueries({ + queryKey: basketQueries.basketState().queryKey, + }) }, }) } 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 60166521e1..0f829c1d10 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx @@ -10,11 +10,7 @@ import { } from "@/test-utils" import * as mitxonline from "api/mitxonline-test-utils" import { mitxonlineLegacyUrl } from "@/common/mitxonline" -import { - makeRequest, - urls as learnUrls, - factories as learnFactories, -} from "api/test-utils" +import { makeRequest } from "api/test-utils" import { faker } from "@faker-js/faker/locale/en" import moment from "moment" import { EnrolledCourseCard } from "./EnrolledCourseCard" @@ -24,17 +20,6 @@ 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 1eb52c27e3..3cc426c761 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx @@ -26,7 +26,6 @@ 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" @@ -39,17 +38,6 @@ 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 6646b0d366..13f063f6fa 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -7,11 +7,7 @@ import { user, within, } from "@/test-utils" -import { - makeRequest, - urls as learnUrls, - factories as learnFactories, -} from "api/test-utils" +import { makeRequest } from "api/test-utils" import * as mitxonline from "api/mitxonline-test-utils" import { ProgramAsCourseCard } from "./ProgramAsCourseCard" import { waitFor } from "@testing-library/react" @@ -26,15 +22,6 @@ 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 dc84ddb308..6133166069 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx @@ -27,11 +27,7 @@ import { } from "@/test-utils" import { ProgramEnrollmentDisplay } from "./ProgramEnrollmentDisplay" import * as mitxonline from "api/mitxonline-test-utils" -import { - makeRequest, - urls as learnUrls, - factories as learnFactories, -} from "api/test-utils" +import { makeRequest } from "api/test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" import { faker } from "@faker-js/faker/locale/en" import invariant from "tiny-invariant" @@ -44,15 +40,6 @@ 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 457a305891..ad6547b9fe 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx @@ -9,11 +9,7 @@ import { within, } from "@/test-utils" import * as mitxonline from "api/mitxonline-test-utils" -import { - makeRequest, - urls as learnUrls, - factories as learnFactories, -} from "api/test-utils" +import { makeRequest } from "api/test-utils" import { faker } from "@faker-js/faker/locale/en" import moment from "moment" import { cartesianProduct } from "ol-test-utilities" @@ -29,17 +25,6 @@ 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 e682e98ffa..8e455edda4 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx @@ -7,12 +7,7 @@ import { user, setupLocationMock, } from "@/test-utils" -import { - makeRequest, - setMockResponse, - urls as learnUrls, - factories as learnFactories, -} from "api/test-utils" +import { makeRequest, setMockResponse } from "api/test-utils" import { urls as mitxUrls, factories as mitxFactories, @@ -67,15 +62,6 @@ 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 e1a4ec91714e4e852378c03154953eaf9fcb19b9 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Thu, 6 Aug 2026 17:25:46 -0400 Subject: [PATCH 2/4] Cover the anonymous_basket_id handoff end to end Nothing asserted the feature's actual output. useReplaceBasketItem.test mocks api/mitxonline-hooks/baskets wholesale so the real hook never ran, and the one test that did run it fed the hook a response with no anonymous_id -- so the seam between "read the field off the API response" and "put it on the redirect URL" was untested. The fixture it used, { id: 1, items: [] }, was not a BasketWithProduct either: `items` is not a field (it is basket_items) and four required ones were missing. setMockResponse.post takes unknown, so TS did not catch it. Adds baskets factories alongside the other mitxonline ones: basket() for a signed-in user, anonymousBasket() for the other case. The two are mutually exclusive -- MITx Online enforces that with a check constraint, basket_user_xor_anonymous_id -- so a caller who has to remember both `user: null` and an `anonymous_id` can get it half right. Two named constructors plus an XOR invariant cannot: setting both, or neither, throws where the fixture is built rather than surfacing as a confusing assertion failure later. The invariant tests for null/undefined rather than truthiness, so a user id of 0 still counts as a user. Tests read the generated id back off the factory instead of threading a uuid through, which also retires the "abc-123" fixture: MITx Online's handoff middleware parses the value with uuid.UUID() and logs-and-ignores anything that does not, so the old fixture encoded a value that would fail in production. The anonymous case now runs through the real hook from mocked HTTP response to asserted redirect URL. Verified it fails if cartUrl stops appending the param. Drops "does not append anonymous_basket_id when the basket belongs to a real user". The factory default is anonymous_id: null, so the two redirect tests above it already feed that exact basket and assert exact URL equality, which is strictly stronger than asserting the param is absent. Co-Authored-By: Claude Opus 5 (1M context) --- .../test-utils/factories/baskets.ts | 42 +++++++++++++++++++ .../mitxonline/test-utils/factories/index.ts | 2 + .../ProductPages/useCourseEnrollment.test.tsx | 26 +++++++++--- .../mitxonline/useReplaceBasketItem.test.tsx | 41 +++++------------- 4 files changed, 75 insertions(+), 36 deletions(-) create mode 100644 frontends/api/src/mitxonline/test-utils/factories/baskets.ts diff --git a/frontends/api/src/mitxonline/test-utils/factories/baskets.ts b/frontends/api/src/mitxonline/test-utils/factories/baskets.ts new file mode 100644 index 0000000000..ce0be6747d --- /dev/null +++ b/frontends/api/src/mitxonline/test-utils/factories/baskets.ts @@ -0,0 +1,42 @@ +import { faker } from "@faker-js/faker/locale/en" +import invariant from "tiny-invariant" +import type { BasketWithProduct } from "@mitodl/mitxonline-api-axios/v2" + +const isSet = (value: unknown) => value !== null && value !== undefined + +/** + * A basket owned by a signed-in user. See `anonymousBasket` for the other case: + * `user` and `anonymous_id` are mutually exclusive, and MITx Online enforces + * that with a database constraint (`basket_user_xor_anonymous_id`). + */ +const basket = ( + overrides: Partial = {}, +): BasketWithProduct => { + const merged: BasketWithProduct = { + id: faker.number.int({ min: 1 }), + user: faker.number.int({ min: 1 }), + anonymous_id: null, + basket_items: [], + total_price: 0, + discounted_price: 0, + discounts: [], + ...overrides, + } + invariant( + isSet(merged.user) !== isSet(merged.anonymous_id), + "A basket has exactly one of `user` and `anonymous_id`. Use basket() for a signed-in user, anonymousBasket() otherwise.", + ) + return merged +} + +/** + * A basket built before the learner signed in. `anonymous_id` is a real UUID: + * MITx Online's handoff middleware parses it with `uuid.UUID()` and + * logs-and-ignores anything that doesn't. + */ +const anonymousBasket = ( + overrides: Partial = {}, +): BasketWithProduct => + basket({ user: null, anonymous_id: faker.string.uuid(), ...overrides }) + +export { basket, anonymousBasket } diff --git a/frontends/api/src/mitxonline/test-utils/factories/index.ts b/frontends/api/src/mitxonline/test-utils/factories/index.ts index 382eeeb8d7..4ca56b118d 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/index.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/index.ts @@ -8,6 +8,7 @@ import * as user from "./user" import * as requirements from "./requirements" import * as contracts from "./contracts" import * as orders from "./orders" +import * as baskets from "./baskets" export { mitx as enrollment, @@ -20,4 +21,5 @@ export { requirements, contracts, orders, + baskets, } diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx index 96897a9da1..b72010f029 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx @@ -12,6 +12,7 @@ import { factories as mitxFactories, urls as mitxUrls, } from "api/mitxonline-test-utils" +import { mitxonlineLegacyUrl } from "@/common/mitxonline" import { useCourseEnrollment } from "./useCourseEnrollment" import type { EnrollActionKind } from "./useCourseEnrollment" import { getSelectedRun } from "./courseRun" @@ -369,7 +370,7 @@ describe("useCourseEnrollment — actions", () => { const clearUrl = mitxUrls.baskets.clear() const basketUrl = mitxUrls.baskets.createFromProduct(product.id) setMockResponse.delete(clearUrl, undefined) - setMockResponse.post(basketUrl, { id: 1, items: [] }) + setMockResponse.post(basketUrl, mitxFactories.baskets.basket()) const { result } = renderHook(() => useCourseEnrollment(course, run), { wrapper, @@ -569,8 +570,9 @@ describe("useCourseEnrollment — actions", () => { ) const clearUrl = mitxUrls.baskets.clear() const basketUrl = mitxUrls.baskets.createFromProduct(product.id) + const anonymous = mitxFactories.baskets.anonymousBasket() setMockResponse.delete(clearUrl, undefined) - setMockResponse.post(basketUrl, { id: 1, items: [] }) + setMockResponse.post(basketUrl, anonymous) const onRequireSignup = jest.fn() @@ -607,6 +609,18 @@ describe("useCourseEnrollment — actions", () => { expect.objectContaining({ method: "post", url: basketUrl }), ) expect(onRequireSignup).not.toHaveBeenCalled() + + // The anonymous basket's id comes back in the API response and has to reach + // MITx Online through the redirect URL: the session cookie that identifies + // the basket is host-only and cannot cross from Learn's API host to + // mitxonline's own domain. + const expectedUrl = new URL(mitxonlineLegacyUrl("/cart/")) + expectedUrl.searchParams.set("anonymous_basket_id", anonymous.anonymous_id!) + await waitFor(() => + expect(window.location.assign).toHaveBeenCalledWith( + expectedUrl.toString(), + ), + ) }) test("unauthenticated paid click requires signup when the anonymous-checkout flag is off", async () => { @@ -670,10 +684,10 @@ describe("useCourseEnrollment — actions", () => { const course = makeCourse({ next_run_id: run.id, courseruns: [run] }) setMockResponse.delete(mitxUrls.baskets.clear(), undefined) - setMockResponse.post(mitxUrls.baskets.createFromProduct(product.id), { - id: 1, - items: [], - }) + setMockResponse.post( + mitxUrls.baskets.createFromProduct(product.id), + mitxFactories.baskets.basket(), + ) const { result } = renderHook( () => diff --git a/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx b/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx index 7fd131b480..276d12bade 100644 --- a/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx +++ b/frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx @@ -1,18 +1,10 @@ import { act, renderHook, setupLocationMock } from "@/test-utils" import { mitxonlineLegacyUrl } from "@/common/mitxonline" +import { factories as mitxFactories } from "api/mitxonline-test-utils" 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, -}) +const { basket, anonymousBasket } = mitxFactories.baskets /** * `useAddToBasket().mutate` hands the created basket to `onSuccess` — the hook @@ -122,8 +114,9 @@ describe("useReplaceBasketItem", () => { test("appends anonymous_basket_id to the redirect url for an anonymous basket (sync path)", () => { const assign = jest.mocked(window.location.assign) + const anonymous = anonymousBasket() mutate.mockImplementationOnce((_productId, opts) => - opts?.onSuccess?.(basket({ id: 9, anonymous_id: "abc-123" })), + opts?.onSuccess?.(anonymous), ) const { result } = renderHook(() => useReplaceBasketItem()) @@ -132,14 +125,15 @@ describe("useReplaceBasketItem", () => { }) const calledUrl = new URL(assign.mock.calls[0][0]) - expect(calledUrl.searchParams.get("anonymous_basket_id")).toBe("abc-123") + expect(calledUrl.searchParams.get("anonymous_basket_id")).toBe( + anonymous.anonymous_id, + ) }) 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( - basket({ id: 9, anonymous_id: "abc-123" }), - ) + const anonymous = anonymousBasket() + mutateAsync.mockResolvedValueOnce(anonymous) const { result } = renderHook(() => useReplaceBasketItem()) await act(async () => { @@ -147,21 +141,8 @@ describe("useReplaceBasketItem", () => { }) 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, opts) => - opts?.onSuccess?.(basket({ id: 9, user: 1, anonymous_id: null })), + expect(calledUrl.searchParams.get("anonymous_basket_id")).toBe( + anonymous.anonymous_id, ) - 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) }) }) From 8f1efc74f66c0f407131c31c00b08066431df80d Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Thu, 6 Aug 2026 17:25:53 -0400 Subject: [PATCH 3/4] Drop comments that restate the code they sit above Three comment blocks were added to each enrollment hook. The one above the auth guard earns its place -- it explains why the guard has that shape, which is not obvious from the condition. The other two restate the code, and the second restates the guard ten lines above it. Also drops sso_organization_id: null from an OrganizationCards test about logo: undefined, where it is an unrelated override. Co-Authored-By: Claude Opus 5 (1M context) --- .../CoursewareDisplay/OrganizationCards.test.tsx | 1 - .../main/src/app-pages/ProductPages/useCourseEnrollment.ts | 7 ------- .../src/app-pages/ProductPages/useProgramEnrollment.ts | 7 ------- 3 files changed, 15 deletions(-) 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 3d87220b96..dc3bd67df7 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx @@ -348,7 +348,6 @@ describe("OrganizationCards", () => { name: "Test Organization", logo: undefined, slug: "org-test-org", - sso_organization_id: null, contracts: [ mitxOnlineFactories.contracts.contract({ id: 1, diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts index 83e01ae6bb..9ec5aa5d87 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts @@ -107,19 +107,12 @@ 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 - // (handled by the guard above). if (selectedRun) { createEnrollment.mutate( { run_id: selectedRun.id }, diff --git a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts index aabd2b6470..71cfb35bb4 100644 --- a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts @@ -90,18 +90,11 @@ export const useProgramEnrollment = ( 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 - // (handled by the guard above). createProgramEnrollment.mutate( { V3ProgramEnrollmentRequestRequest: { program_id: program.id } }, { From ca35b997b435e5fe041cc97cad7c0b9e7013da78 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Thu, 6 Aug 2026 20:46:58 -0400 Subject: [PATCH 4/4] Use the baskets factory for the remaining basket fixtures The { id: 1, items: [] } stand-in the previous commit replaced appears at eleven more mock sites across seven enrollment and dashboard test files. None of them read the response, so nothing was failing -- but nothing would start failing either if the real shape drifted, since setMockResponse.post takes unknown and TS never checks these. Mechanical: no assertions change. Co-Authored-By: Claude Opus 5 (1M context) --- .../CoursewareDisplay/EnrolledCourseCard.test.tsx | 4 ++-- .../CoursewareDisplay/ProgramAsCourseCard.test.tsx | 2 +- .../CoursewareDisplay/UnenrolledCourseCard.test.tsx | 4 ++-- .../app-pages/ProductPages/CourseEnrollArea.test.tsx | 8 ++++---- .../src/app-pages/ProductPages/CoursePage.test.tsx | 8 ++++---- .../ProductPages/useProgramEnrollment.test.tsx | 12 ++++++------ .../CourseEnrollmentDialog.test.tsx | 2 +- 7 files changed, 20 insertions(+), 20 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..eca7f44202 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx @@ -440,7 +440,7 @@ describe.each([ const clearUrl = mitxonline.urls.baskets.clear() setMockResponse.delete(clearUrl, undefined) const basketUrl = mitxonline.urls.baskets.createFromProduct(productId) - setMockResponse.post(basketUrl, { id: 1, items: [] }) + setMockResponse.post(basketUrl, mitxonline.factories.baskets.basket()) renderWithProviders() await user.click( @@ -641,7 +641,7 @@ describe.each([ const clearUrl = mitxonline.urls.baskets.clear() setMockResponse.delete(clearUrl, undefined) const basketUrl = mitxonline.urls.baskets.createFromProduct(productId) - setMockResponse.post(basketUrl, { id: 1, items: [] }) + setMockResponse.post(basketUrl, mitxonline.factories.baskets.basket()) renderWithProviders( { const clearUrl = mitxonline.urls.baskets.clear() setMockResponse.delete(clearUrl, undefined) const basketUrl = mitxonline.urls.baskets.createFromProduct(product.id) - setMockResponse.post(basketUrl, { id: 1, items: [] }) + setMockResponse.post(basketUrl, mitxonline.factories.baskets.basket()) renderWithProviders( ) @@ -793,7 +793,7 @@ describe.each([ const clearUrl = mitxonline.urls.baskets.clear() setMockResponse.delete(clearUrl, undefined) const basketUrl = mitxonline.urls.baskets.createFromProduct(product.id) - setMockResponse.post(basketUrl, { id: 1, items: [] }) + setMockResponse.post(basketUrl, mitxonline.factories.baskets.basket()) renderWithProviders( { const course = makeCourse({ next_run_id: run.id, courseruns: [run] }) setMockResponse.delete(mitxUrls.baskets.clear(), undefined) - setMockResponse.post(mitxUrls.baskets.createFromProduct(product.id), { - id: 1, - items: [], - }) + setMockResponse.post( + mitxUrls.baskets.createFromProduct(product.id), + mitxFactories.baskets.basket(), + ) renderWithProviders() diff --git a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx index 02f591abc2..5585688634 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx @@ -485,10 +485,10 @@ describe("CoursePage", () => { ) setMockResponse.get(mitxUrls.enrollment.enrollmentsListV3(), []) setMockResponse.delete(mitxUrls.baskets.clear(), undefined) - setMockResponse.post(mitxUrls.baskets.createFromProduct(product.id), { - id: 1, - items: [], - }) + setMockResponse.post( + mitxUrls.baskets.createFromProduct(product.id), + mitxFactories.baskets.basket(), + ) renderWithProviders() diff --git a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx index bb20837a5d..b53bd251af 100644 --- a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.test.tsx @@ -241,10 +241,10 @@ describe("useProgramEnrollment — actions", () => { products: [product], }) setMockResponse.delete(mitxUrls.baskets.clear(), undefined) - setMockResponse.post(mitxUrls.baskets.createFromProduct(product.id), { - id: 1, - items: [], - }) + setMockResponse.post( + mitxUrls.baskets.createFromProduct(product.id), + mitxFactories.baskets.basket(), + ) const { result } = renderHook( () => @@ -374,7 +374,7 @@ describe("useProgramEnrollment — actions", () => { const clearUrl = mitxUrls.baskets.clear() const basketUrl = mitxUrls.baskets.createFromProduct(product.id) setMockResponse.delete(clearUrl, undefined) - setMockResponse.post(basketUrl, { id: 1, items: [] }) + setMockResponse.post(basketUrl, mitxFactories.baskets.basket()) const { result } = renderHook(() => useProgramEnrollment(program), { wrapper, @@ -412,7 +412,7 @@ describe("useProgramEnrollment — actions", () => { const clearUrl = mitxUrls.baskets.clear() const basketUrl = mitxUrls.baskets.createFromProduct(product.id) setMockResponse.delete(clearUrl, undefined) - setMockResponse.post(basketUrl, { id: 1, items: [] }) + setMockResponse.post(basketUrl, mitxFactories.baskets.basket()) const onRequireSignup = jest.fn() diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx index 8e455edda4..b53d3c55ee 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx @@ -364,7 +364,7 @@ describe("CourseEnrollmentDialog", () => { const clearUrl = mitxUrls.baskets.clear() setMockResponse.delete(clearUrl, undefined) const basketUrl = mitxUrls.baskets.createFromProduct(product.id) - setMockResponse.post(basketUrl, { id: 1, items: [] }) + setMockResponse.post(basketUrl, mitxFactories.baskets.basket()) renderWithProviders(
) await openDialog(course)