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
23 changes: 21 additions & 2 deletions frontends/api/src/mitxonline/hooks/organizations/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
import { organizationQueries, useB2BAttachMutation } from "./queries"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { b2bApi } from "../../clients"
import { B2bApiB2bAttachCreateRequest } from "@mitodl/mitxonline-api-axios/v2"
import { organizationQueries, managerOrganizationQueries } from "./queries"

export { organizationQueries, useB2BAttachMutation }
const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
const response = await b2bApi.b2bAttachCreate(opts)
// 200 (already attached) indicates user already attached to all contracts
// 201 (successfully attached) is success
// 404 (invalid or expired code) will be thrown as error by axios
return response
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mitxonline"] })
},
})
}

export { organizationQueries, managerOrganizationQueries, useB2BAttachMutation }
62 changes: 41 additions & 21 deletions frontends/api/src/mitxonline/hooks/organizations/queries.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import {
queryOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query"
import { queryOptions } from "@tanstack/react-query"
import { b2bApi } from "../../clients"
import {
OrganizationPage,
B2bApiB2bAttachCreateRequest,
ManagerContractDetail,
B2bApiB2bOrganizationsRetrieveRequest,
B2bApiB2bManagerOrganizationsContractsRetrieveRequest,
} from "@mitodl/mitxonline-api-axios/v2"

const organizationKeys = {
Expand All @@ -29,20 +26,43 @@ const organizationQueries = {
}),
}

const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
const response = await b2bApi.b2bAttachCreate(opts)
// 200 (already attached) indicates user already attached to all contracts
// 201 (successfully attached) is success
// 404 (invalid or expired code) will be thrown as error by axios
return response
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mitxonline"] })
},
})
const managerOrganizationKeys = {
list: () => ["mitxonline", "manager", "organizations", "list"] as const,
contractDetail: (
opts: B2bApiB2bManagerOrganizationsContractsRetrieveRequest,
) =>
[
"mitxonline",
"manager",
"organizations",
"contracts",
"detail",
opts,
] as const,
}

const managerOrganizationQueries = {
managerOrganizationsList: () =>
queryOptions({
queryKey: managerOrganizationKeys.list(),
queryFn: async (): Promise<OrganizationPage[]> =>
b2bApi.b2bManagerOrganizationsList().then((res) => res.data),
}),
managerContractDetail: (
opts: B2bApiB2bManagerOrganizationsContractsRetrieveRequest,
) =>
queryOptions({
queryKey: managerOrganizationKeys.contractDetail(opts),
queryFn: async (): Promise<ManagerContractDetail> =>
b2bApi
.b2bManagerOrganizationsContractsRetrieve(opts)
.then((res) => res.data),
}),
}

export { organizationQueries, organizationKeys, useB2BAttachMutation }
export {
organizationQueries,
organizationKeys,
managerOrganizationQueries,
managerOrganizationKeys,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React from "react"
import { renderWithProviders, screen } from "@/test-utils"
import { setMockResponse } from "api/test-utils"
import { factories } from "api/mitxonline-test-utils"
import { useFeatureFlagEnabled } from "posthog-js/react"
import { allowConsoleErrors } from "ol-test-utilities"
import { ForbiddenError } from "@/common/errors"
import ContractAdminPage from "./ContractAdminPage"

jest.mock("posthog-js/react", () => ({
...jest.requireActual("posthog-js/react"),
useFeatureFlagEnabled: jest.fn(),
}))
const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled)

const API_BASE_URL = process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL
const managerOrgsUrl = `${API_BASE_URL}/api/v0/b2b/manager/organizations/`
const managerContractDetailUrl = (orgId: number, contractId: number) =>
`${API_BASE_URL}/api/v0/b2b/manager/organizations/${orgId}/contracts/${contractId}/`

const makeOrgWithContract = () => {
const contract = factories.contracts.contract()
const org = factories.organizations.organization({ contracts: [contract] })
return { org, contract }
}

describe("ContractAdminPage", () => {
beforeEach(() => {
mockedUseFeatureFlagEnabled.mockReturnValue(undefined)
})

test("throws ForbiddenError when feature flag is explicitly false", () => {
mockedUseFeatureFlagEnabled.mockReturnValue(false)
allowConsoleErrors()

expect(() =>
renderWithProviders(
<ContractAdminPage orgSlug="any-org" contractSlug="any-contract" />,
),
).toThrow(ForbiddenError)
})

test("renders nothing while feature flag is loading (undefined)", () => {
mockedUseFeatureFlagEnabled.mockReturnValue(undefined)

const { view } = renderWithProviders(
<ContractAdminPage orgSlug="any-org" contractSlug="any-contract" />,
)

expect(view.container.firstChild).toBeNull()
})

test("shows 'Organization not found' when user is not a manager for the requested org", async () => {
mockedUseFeatureFlagEnabled.mockReturnValue(true)

const otherOrg = factories.organizations.organization({})
setMockResponse.get(managerOrgsUrl, [otherOrg])

renderWithProviders(
<ContractAdminPage orgSlug="not-my-org" contractSlug="some-contract" />,
)

await screen.findByRole("heading", { name: "Organization not found" })
})

test("shows 'Contract not found' when org is found but contract slug does not match", async () => {
mockedUseFeatureFlagEnabled.mockReturnValue(true)

const { org } = makeOrgWithContract()
setMockResponse.get(managerOrgsUrl, [org])

renderWithProviders(
<ContractAdminPage
orgSlug={org.slug}
contractSlug="wrong-contract-slug"
/>,
)

await screen.findByRole("heading", { name: "Contract not found" })
})

test("renders org name and contract name when flag is on and user is a manager", async () => {
mockedUseFeatureFlagEnabled.mockReturnValue(true)

const { org, contract } = makeOrgWithContract()
setMockResponse.get(managerOrgsUrl, [org])
setMockResponse.get(managerContractDetailUrl(org.id, contract.id), {
...contract,
attachment_percentage: null,
total_enrollments: 0,
total_codes: 50,
})

renderWithProviders(
<ContractAdminPage orgSlug={org.slug} contractSlug={contract.slug} />,
)

await screen.findByRole("heading", { name: org.name })
expect(screen.getByText(contract.name)).toBeInTheDocument()
})

test("renders seat count from contract detail", async () => {
mockedUseFeatureFlagEnabled.mockReturnValue(true)

const { org, contract } = makeOrgWithContract()
setMockResponse.get(managerOrgsUrl, [org])
setMockResponse.get(managerContractDetailUrl(org.id, contract.id), {
...contract,
attachment_percentage: null,
total_enrollments: 12,
total_codes: 75,
})

renderWithProviders(
<ContractAdminPage orgSlug={org.slug} contractSlug={contract.slug} />,
)

await screen.findByText("75 seats")
})
})
Loading
Loading