Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fetch restricted runs and display if unrestricted for redeemable catalog #1155

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
5 changes: 5 additions & 0 deletions src/components/app/data/hooks/useCourseMetadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export default function useCourseMetadata(queryOptions = {}) {
if (!data) {
return data;
}
// NOTE: The results from this call includes restricted runs, some of
// which might not be ACTUALLY available depending on the subsidy being
// applied. However, we don't know the subsidy being applied at this
// point of the code, so just return all of the basically available
// restricted runs regardless of catalog inclusion.
const availableCourseRuns = getAvailableCourseRuns({ course: data, lateEnrollmentBufferDays });
let transformedData = {
...data,
Expand Down
176 changes: 120 additions & 56 deletions src/components/app/data/hooks/useCourseMetadata.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import { fetchCourseMetadata } from '../services';
import useLateEnrollmentBufferDays from './useLateEnrollmentBufferDays';
import useCourseMetadata from './useCourseMetadata';
import useRedeemablePolicies from './useRedeemablePolicies';
import useEnterpriseCustomerContainsContent from './useEnterpriseCustomerContainsContent';
import { ENTERPRISE_RESTRICTION_TYPE } from '../../../../constants';

jest.mock('./useEnterpriseCustomer');
jest.mock('./useLateEnrollmentBufferDays');
jest.mock('./useRedeemablePolicies');
jest.mock('./useEnterpriseCustomerContainsContent');

jest.mock('../services', () => ({
...jest.requireActual('../services'),
Expand All @@ -24,14 +27,47 @@ jest.mock('react-router-dom', () => ({

const mockCourseMetadata = {
key: 'edX+DemoX',
courseRuns: [{
isMarketable: true,
availability: 'Current',
enrollmentStart: dayjs().add(10, 'day').toISOString(),
enrollmentEnd: dayjs().add(15, 'day').toISOString(),
key: 'course-v1:edX+DemoX+2T2020',
isEnrollable: true,
}],
courseRuns: [
// Happy case, should appear in the output.
{
isMarketable: true,
availability: 'Current',
enrollmentStart: dayjs().add(-10, 'day').toISOString(),
enrollmentEnd: dayjs().add(15, 'day').toISOString(),
key: 'course-v1:edX+DemoX+2T2020',
isEnrollable: true,
restrictionType: null,
},
// Throw in a non-marketable run.
{
isMarketable: false,
availability: 'Current',
enrollmentStart: dayjs().add(-10, 'day').toISOString(),
enrollmentEnd: dayjs().add(15, 'day').toISOString(),
key: 'course-v1:edX+DemoX+2T2020unmarketable',
isEnrollable: true,
restrictionType: null,
},
// Throw in a couple restricted runs.
{
isMarketable: true,
availability: 'Current',
enrollmentStart: dayjs().add(-10, 'day').toISOString(),
enrollmentEnd: dayjs().add(15, 'day').toISOString(),
key: 'course-v1:edX+DemoX+2T2020restricted.a',
isEnrollable: true,
restrictionType: ENTERPRISE_RESTRICTION_TYPE,
},
{
isMarketable: true,
availability: 'Current',
enrollmentStart: dayjs().add(-10, 'day').toISOString(),
enrollmentEnd: dayjs().add(15, 'day').toISOString(),
key: 'course-v1:edX+DemoX+2T2020restricted.b',
isEnrollable: true,
restrictionType: ENTERPRISE_RESTRICTION_TYPE,
},
],
};

const mockBaseRedeemablePolicies = {
Expand Down Expand Up @@ -69,6 +105,7 @@ describe('useCourseMetadata', () => {
useLateEnrollmentBufferDays.mockReturnValue(undefined);
useSearchParams.mockReturnValue([new URLSearchParams({ course_run_key: 'course-v1:edX+DemoX+2T2024' })]);
useRedeemablePolicies.mockReturnValue({ data: mockBaseRedeemablePolicies });
useEnterpriseCustomerContainsContent.mockReturnValue({ data: {} });
});
it('should handle resolved value correctly with no select function passed', async () => {
const { result, waitForNextUpdate } = renderHook(() => useCourseMetadata(), { wrapper: Wrapper });
Expand All @@ -78,7 +115,11 @@ describe('useCourseMetadata', () => {
expect.objectContaining({
data: {
...mockCourseMetadata,
availableCourseRuns: [mockCourseMetadata.courseRuns[0]],
availableCourseRuns: [
mockCourseMetadata.courseRuns[0],
mockCourseMetadata.courseRuns[2],
mockCourseMetadata.courseRuns[3],
],
},
isLoading: false,
isFetching: false,
Expand Down Expand Up @@ -110,7 +151,11 @@ describe('useCourseMetadata', () => {
original: mockCourseMetadata,
transformed: {
...mockCourseMetadata,
availableCourseRuns: [mockCourseMetadata.courseRuns[0]],
availableCourseRuns: [
mockCourseMetadata.courseRuns[0],
mockCourseMetadata.courseRuns[2],
mockCourseMetadata.courseRuns[3],
],
},
},
isLoading: false,
Expand Down Expand Up @@ -138,41 +183,54 @@ describe('useCourseMetadata', () => {
useLateEnrollmentBufferDays.mockReturnValue(undefined);
useSearchParams.mockReturnValue([new URLSearchParams({})]);

const mockCourseRuns = [
...mockCourseMetadata.courseRuns,
{
...mockCourseMetadata.courseRuns[0],
key: 'course-v1:edX+DemoX+2018',
const availableCourseRuns = [
mockCourseMetadata.courseRuns[0], // This is marketable, enrollable, unrestricted, etc.
mockCourseMetadata.courseRuns[2], // This is restricted, but that doesn't disqualify it.
];
const unavailableCourseRuns = [
mockCourseMetadata.courseRuns[1], // This one is unavailable due to being unmarketable.
];
// Copy all the above runs to make both assigned and unassigned versions.
const courseRunsMatrix = {
available: {
assigned: availableCourseRuns.map(r => ({ ...r, key: `${r.key}assigned` })),
unassigned: availableCourseRuns.map(r => ({ ...r, key: `${r.key}unassigned` })),
},
unavailable: {
assigned: unavailableCourseRuns.map(r => ({ ...r, key: `${r.key}assigned` })),
unassigned: unavailableCourseRuns.map(r => ({ ...r, key: `${r.key}unassigned` })),
},
};
// Recombine all the generated runs into useful lists to pass to mock objects:
const assignedCourseRuns = [
...courseRunsMatrix.available.assigned,
...courseRunsMatrix.unavailable.assigned,
];
const availableAndAssignedCourseRuns = [
...courseRunsMatrix.available.assigned,
];
const allCourseRuns = [
...courseRunsMatrix.available.assigned,
...courseRunsMatrix.available.unassigned,
...courseRunsMatrix.unavailable.assigned,
...courseRunsMatrix.unavailable.unassigned,
];

const mockUnassignedCourseRun = {
...mockCourseMetadata.courseRuns[0],
key: 'course-v1:edX+DemoX+3T2024',
};
// Since there's no URL param asking for a specific run, all runs will be returned.
fetchCourseMetadata.mockResolvedValue({
...mockCourseMetadata, courseRuns: allCourseRuns,
});

const mockAllocatedAssignments = [{
parentContentKey: 'edX+DemoX',
contentKey: 'course-v1:edX+DemoX+2T2020',
isAssignedCourseRun: true,
},
{
parentContentKey: 'edX+DemoX',
contentKey: 'course-v1:edX+DemoX+2018',
isAssignedCourseRun: true,
}, {
parentContentKey: null,
contentKey: 'edX+DemoX',
isAssignedCourseRun: false,
}];
const mockLearnerContentAssignments = {
allocatedAssignments: mockAllocatedAssignments,
hasAllocatedAssignments: mockAllocatedAssignments.length > 0,
allocatedAssignments: assignedCourseRuns.map(
run => ({
parentContentKey: 'edX+DemoX',
contentKey: run.key,
isAssignedCourseRun: true,
}),
),
hasAllocatedAssignments: true,
};

fetchCourseMetadata.mockResolvedValue({
...mockCourseMetadata, courseRuns: [...mockCourseRuns, mockUnassignedCourseRun],
});
useRedeemablePolicies.mockReturnValue({
data: {
...mockBaseRedeemablePolicies,
Expand All @@ -189,8 +247,8 @@ describe('useCourseMetadata', () => {
expect.objectContaining({
data: {
...mockCourseMetadata,
courseRuns: mockCourseRuns,
availableCourseRuns: mockCourseRuns,
courseRuns: assignedCourseRuns,
availableCourseRuns: availableAndAssignedCourseRuns,
},
isLoading: false,
isFetching: false,
Expand All @@ -207,25 +265,26 @@ describe('useCourseMetadata', () => {
key: 'course-v1:edX+DemoX+2018',
}];

const mockAllocatedAssignments = [{
parentContentKey: 'edX+DemoX',
contentKey: 'course-v1:edX+DemoX+2T2020',
isAssignedCourseRun: true,
},
{
parentContentKey: 'edX+DemoX',
contentKey: 'course-v1:edX+DemoX+2018',
isAssignedCourseRun: true,
}, {
parentContentKey: null,
contentKey: 'edX+DemoX',
isAssignedCourseRun: false,
}];
const mockAllocatedAssignments = [
// Run for this assignment not requested in query param, so will not affect output.
{
parentContentKey: 'edX+DemoX',
contentKey: 'course-v1:edX+DemoX+2T2020',
isAssignedCourseRun: true,
},
// Run for this assignment is present in query param, so the output should contain metadata for this run.
{
parentContentKey: 'edX+DemoX',
contentKey: 'course-v1:edX+DemoX+2018',
isAssignedCourseRun: true,
},
];
const mockLearnerContentAssignments = {
allocatedAssignments: mockAllocatedAssignments,
hasAllocatedAssignments: mockAllocatedAssignments.length > 0,
hasAllocatedAssignments: true,
};

// Since there's a URL param asking for a specific run, only that run will be returned.
fetchCourseMetadata.mockResolvedValue({
...mockCourseMetadata, courseRuns: mockCourseRun,
});
Expand All @@ -241,10 +300,15 @@ describe('useCourseMetadata', () => {
const { result, waitForNextUpdate } = renderHook(() => useCourseMetadata(), { wrapper: Wrapper });
await waitForNextUpdate();

// The actual thing uniquely tested in this unit test is if the URL param gets passed to fetchCourseMetadata().
expect(fetchCourseMetadata.mock.calls).toEqual([
['edX+DemoX', 'course-v1:edX+DemoX+2018'],
]);
expect(result.current).toEqual(
expect.objectContaining({
data: {
...mockCourseMetadata,
// The requested run is available and assigned, so should appear in both lists below:
courseRuns: mockCourseRun,
availableCourseRuns: mockCourseRun,
},
Expand Down
45 changes: 39 additions & 6 deletions src/components/app/data/hooks/useCourseRedemptionEligibility.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';

import { ENTERPRISE_RESTRICTION_TYPE } from '../../../../constants';
import useCourseMetadata from './useCourseMetadata';
import { queryCanRedeem } from '../queries';
import useEnterpriseCustomer from './useEnterpriseCustomer';
Expand All @@ -26,24 +27,54 @@
canRedeemData,
courseRunKey,
}) {
const redeemabilityForActiveCourseRun = canRedeemData.find(r => r.contentKey === courseMetadata.activeCourseRun?.key);
// Begin by excluding restricted runs that should not be visible to the requester.
//
// NOTE: This filtering does ultimately control the visibility of individual course runs
// on the course about page IFF the applicable subsidy type is Learner Credit.
const availableCourseRuns = courseMetadata.availableCourseRuns.filter(courseRunMetadata => {
if (!courseRunMetadata.restrictionType) {
// If a run is generally unrestricted, always show the run. Pre-filtering on the
// upstream `courseMetadata.availableCourseRuns` already excluded runs that are
// unpublished, unmarketable, etc.
return true;
}
if (courseRunMetadata.restrictionType !== ENTERPRISE_RESTRICTION_TYPE) {
// We completely do not support restricted runs that aren't of the enterprise
// variety. unconditionally hide them from learners and pretend they do not exist.
return false;

Check warning on line 44 in src/components/app/data/hooks/useCourseRedemptionEligibility.js

View check run for this annotation

Codecov / codecov/patch

src/components/app/data/hooks/useCourseRedemptionEligibility.js#L44

Added line #L44 was not covered by tests
}
const canRedeemRunData = canRedeemData.find(r => r.contentKey === courseRunMetadata.key);
return !!canRedeemRunData?.canRedeem;

Check warning on line 47 in src/components/app/data/hooks/useCourseRedemptionEligibility.js

View check run for this annotation

Codecov / codecov/patch

src/components/app/data/hooks/useCourseRedemptionEligibility.js#L46-L47

Added lines #L46 - L47 were not covered by tests
});
const availableCourseRunKeys = availableCourseRuns.map(r => r.key);
// From here on, do not consider can-redeem responses for restricted runs that this
// subsidy cannot currently redeem when determining if Learner Credit is eligible as the
// applicable subsidy type. We don't want any existing redemption for a run that should
// be hidden from THIS subsidy to throw off the calculation.
const canRedeemDataForAvailableRuns = canRedeemData.filter(
r => availableCourseRunKeys.includes(r.contentKey),
);
const redeemabilityForActiveCourseRun = canRedeemDataForAvailableRuns.find(
r => r.contentKey === courseMetadata.activeCourseRun?.key,
);
const missingSubsidyAccessPolicyReason = redeemabilityForActiveCourseRun?.reasons[0];
const preferredSubsidyAccessPolicy = redeemabilityForActiveCourseRun?.redeemableSubsidyAccessPolicy;
const otherSubsidyAccessPolicy = canRedeemData.find(
const anyRedeemableSubsidyAccessPolicy = canRedeemDataForAvailableRuns.find(
r => r.redeemableSubsidyAccessPolicy,
)?.redeemableSubsidyAccessPolicy;
const listPrice = getContentListPriceRange({ courseRuns: canRedeemData });
const hasSuccessfulRedemption = courseRunKey
? !!canRedeemData.find(r => r.contentKey === courseRunKey)?.hasSuccessfulRedemption
: canRedeemData.some(r => r.hasSuccessfulRedemption);
? !!canRedeemDataForAvailableRuns.find(r => r.contentKey === courseRunKey)?.hasSuccessfulRedemption
: canRedeemDataForAvailableRuns.some(r => r.hasSuccessfulRedemption);

// If there is a redeemable subsidy access policy for the active course run, use that. Otherwise, use any other
// redeemable subsidy access policy for any of the content keys.
const redeemableSubsidyAccessPolicy = preferredSubsidyAccessPolicy || otherSubsidyAccessPolicy;
const redeemableSubsidyAccessPolicy = preferredSubsidyAccessPolicy || anyRedeemableSubsidyAccessPolicy;
const isPolicyRedemptionEnabled = hasSuccessfulRedemption || !!redeemableSubsidyAccessPolicy;
return {
isPolicyRedemptionEnabled,
redeemabilityPerContentKey: canRedeemData,
redeemabilityPerContentKey: canRedeemDataForAvailableRuns,
availableCourseRuns,
redeemableSubsidyAccessPolicy,
missingSubsidyAccessPolicyReason,
hasSuccessfulRedemption,
Expand All @@ -66,6 +97,8 @@
...queryCanRedeem(enterpriseCustomer.uuid, courseMetadata, lateEnrollmentBufferDays),
enabled: !!courseMetadata,
select: (data) => {
// Among other things, transformCourseRedemptionEligibility() removes
// restricted runs that fail the policy's can-redeem check.
const transformedData = transformCourseRedemptionEligibility({
courseMetadata,
canRedeemData: data,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ import useEnterpriseCustomer from './useEnterpriseCustomer';
import { queryClient } from '../../../../utils/tests';
import { fetchCanRedeem } from '../services';
import useCourseMetadata from './useCourseMetadata';
import { useCourseRedemptionEligibility, useLateEnrollmentBufferDays } from './index';
import {
useCourseRedemptionEligibility,
useLateEnrollmentBufferDays,
useEnterpriseCustomerContainsContent,
} from './index';
import { transformCourseRedemptionEligibility } from './useCourseRedemptionEligibility';

jest.mock('./useEnterpriseCustomer');
jest.mock('./useCourseMetadata');
jest.mock('./useLateEnrollmentBufferDays');
jest.mock('./useEnterpriseCustomerContainsContent');
jest.mock('../services', () => ({
...jest.requireActual('../services'),
fetchCanRedeem: jest.fn().mockResolvedValue(null),
Expand All @@ -25,7 +30,7 @@ const mockEnterpriseCustomer = enterpriseCustomerFactory();
const mockCourseRunKey = 'course-v1:edX+DemoX+T2024';
const mockCourseMetadata = {
key: 'edX+DemoX',
courseRuns: [{
availableCourseRuns: [{
key: mockCourseRunKey,
isMarketable: true,
availability: 'Current',
Expand Down Expand Up @@ -87,6 +92,7 @@ describe('useCourseRedemptionEligibility', () => {
useParams.mockReturnValue({ courseRunKey: mockCourseRunKey });
useCourseMetadata.mockReturnValue({ data: mockCourseMetadata });
useLateEnrollmentBufferDays.mockReturnValue(undefined);
useEnterpriseCustomerContainsContent.mockReturnValue({ data: {} });
});
it('should handle resolved value correctly', async () => {
const { result, waitForNextUpdate } = renderHook(() => useCourseRedemptionEligibility(), { wrapper: Wrapper });
Expand Down
Loading
Loading