diff --git a/src/components/app/data/constants.js b/src/components/app/data/constants.js index 684860440..bfaedf56e 100644 --- a/src/components/app/data/constants.js +++ b/src/components/app/data/constants.js @@ -85,7 +85,7 @@ export const getBaseSubscriptionsData = () => { customerAgreement: null, subscriptionLicense: null, subscriptionPlan: null, - licensesByStatus: _cloneDeep(baseLicensesByStatus), + subscriptionLicensesByStatus: _cloneDeep(baseLicensesByStatus), showExpirationNotifications: false, }; return { diff --git a/src/components/app/data/hooks/useCatalogsForSubsidyRequests.test.jsx b/src/components/app/data/hooks/useCatalogsForSubsidyRequests.test.jsx index e0d0cc55f..4f4f0098b 100644 --- a/src/components/app/data/hooks/useCatalogsForSubsidyRequests.test.jsx +++ b/src/components/app/data/hooks/useCatalogsForSubsidyRequests.test.jsx @@ -2,7 +2,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '../../../../utils/tests'; import useCatalogsForSubsidyRequest from './useCatalogsForSubsidyRequests'; -import { LICENSE_STATUS } from '../../../enterprise-user-subsidy/data/constants'; +import { getBaseSubscriptionsData } from '../constants'; import useSubscriptions from './useSubscriptions'; import { useBrowseAndRequestConfiguration } from './useBrowseAndRequest'; import useCouponCodes from './useCouponCodes'; @@ -17,19 +17,7 @@ jest.mock('../services', () => ({ fetchSubscriptions: jest.fn().mockResolvedValue(null), fetchBrowseAndRequestConfiguration: jest.fn().mockResolvedValue(null), })); -const licensesByStatus = { - [LICENSE_STATUS.ACTIVATED]: [], - [LICENSE_STATUS.ASSIGNED]: [], - [LICENSE_STATUS.REVOKED]: [], -}; -const mockSubscriptionsData = { - subscriptionLicenses: [], - customerAgreement: null, - subscriptionLicense: null, - subscriptionPlan: null, - licensesByStatus, - showExpirationNotifications: false, -}; +const { baseSubscriptionsData } = getBaseSubscriptionsData(); const mockCouponsOverviewResponse = [{ id: 123 }]; const mockBrowseAndRequestConfiguration = { id: 123, @@ -44,7 +32,7 @@ describe('useCatalogsForSubsidyRequests', () => { beforeEach(() => { jest.clearAllMocks(); useBrowseAndRequestConfiguration.mockReturnValue({ data: mockBrowseAndRequestConfiguration }); - useSubscriptions.mockReturnValue({ data: mockSubscriptionsData }); + useSubscriptions.mockReturnValue({ data: baseSubscriptionsData }); useCouponCodes.mockReturnValue({ data: { couponsOverview: mockCouponsOverviewResponse } }); }); it('should handle return when subsidy request not enabled for browseAndRequestConfiguration', () => { @@ -56,7 +44,7 @@ describe('useCatalogsForSubsidyRequests', () => { availableSubscriptionCatalogs: ['test-catalog1', 'test-catalog2'], }; const mockUpdatedSubscriptionsData = { - ...mockSubscriptionsData, + ...baseSubscriptionsData, customerAgreement, }; const mockUpdatedBrowseAndRequestConfiguration = { @@ -118,7 +106,7 @@ describe('useCatalogsForSubsidyRequests', () => { enterpriseCatalogUuid: 'test-catalog6', }]; useBrowseAndRequestConfiguration.mockReturnValue({ data: mockUpdatedBrowseAndRequestConfiguration }); - useSubscriptions.mockReturnValue({ data: mockSubscriptionsData }); + useSubscriptions.mockReturnValue({ data: baseSubscriptionsData }); useCouponCodes.mockReturnValue({ data: { couponsOverview: mockUpdatedCouponsOverviewResponse } }); const { result } = renderHook(() => useCatalogsForSubsidyRequest(), { wrapper: Wrapper }); diff --git a/src/components/app/data/hooks/useSubscriptions.js b/src/components/app/data/hooks/useSubscriptions.js index dffbdb7b4..4af341e1b 100644 --- a/src/components/app/data/hooks/useSubscriptions.js +++ b/src/components/app/data/hooks/useSubscriptions.js @@ -1,7 +1,6 @@ import { querySubscriptions } from '../queries'; import useEnterpriseCustomer from './useEnterpriseCustomer'; import useBFF from './useBFF'; -import { transformSubscriptionsData } from '../services'; /** * Custom hook to get subscriptions data for the enterprise. @@ -16,10 +15,7 @@ export default function useSubscriptions(queryOptions = {}) { bffQueryOptions: { ...queryOptionsRest, select: (data) => { - const transformedData = transformSubscriptionsData( - data?.enterpriseCustomerUserSubsidies?.subscriptions, - { isBFFData: true }, - ); + const transformedData = data?.enterpriseCustomerUserSubsidies?.subscriptions; // When custom `select` function is provided in `queryOptions`, call it with original and transformed data. if (select) { diff --git a/src/components/app/data/hooks/useSubscriptions.test.jsx b/src/components/app/data/hooks/useSubscriptions.test.jsx index f7cf08c76..50859fc6a 100644 --- a/src/components/app/data/hooks/useSubscriptions.test.jsx +++ b/src/components/app/data/hooks/useSubscriptions.test.jsx @@ -9,6 +9,7 @@ import useSubscriptions from './useSubscriptions'; import { LICENSE_STATUS } from '../../../enterprise-user-subsidy/data/constants'; import { queryEnterpriseLearnerDashboardBFF, resolveBFFQuery } from '../queries'; import useEnterpriseFeatures from './useEnterpriseFeatures'; +import { getBaseSubscriptionsData } from '../constants'; jest.mock('./useEnterpriseCustomer'); jest.mock('./useEnterpriseFeatures'); @@ -28,19 +29,7 @@ jest.mock('react-router-dom', () => ({ })); const mockEnterpriseCustomer = enterpriseCustomerFactory(); -const licensesByStatus = { - [LICENSE_STATUS.ACTIVATED]: [], - [LICENSE_STATUS.ASSIGNED]: [], - [LICENSE_STATUS.REVOKED]: [], -}; -const mockSubscriptionsData = { - subscriptionLicenses: [], - customerAgreement: null, - subscriptionLicense: null, - subscriptionPlan: null, - licensesByStatus, - showExpirationNotifications: false, -}; +const { baseSubscriptionsData, baseLicensesByStatus } = getBaseSubscriptionsData(); describe('useSubscriptions', () => { const Wrapper = ({ children }) => ( @@ -53,7 +42,7 @@ describe('useSubscriptions', () => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); useEnterpriseFeatures.mockReturnValue({ data: undefined }); - fetchSubscriptions.mockResolvedValue(mockSubscriptionsData); + fetchSubscriptions.mockResolvedValue(baseSubscriptionsData); useLocation.mockReturnValue({ pathname: '/test-enterprise' }); useParams.mockReturnValue({ enterpriseSlug: 'test-enterprise' }); resolveBFFQuery.mockReturnValue(null); @@ -90,11 +79,11 @@ describe('useSubscriptions', () => { } const queryOptions = hasQueryOptions ? { select: mockSelect } : undefined; const mockSubscriptionLicensesByStatus = { - ...mockSubscriptionsData.licensesByStatus, + ...baseLicensesByStatus, [mockSubscriptionLicense.status]: [mockSubscriptionLicense], }; const mockSubscriptionsDataWithLicense = { - ...mockSubscriptionsData, + ...baseSubscriptionsData, subscriptionLicenses: [mockSubscriptionLicense], customerAgreement: { uuid: 'mock-customer-agreement-uuid', @@ -102,12 +91,11 @@ describe('useSubscriptions', () => { }, subscriptionLicense: mockSubscriptionLicense, subscriptionPlan: mockSubscriptionLicense.subscriptionPlan, - licensesByStatus: mockSubscriptionLicensesByStatus, + subscriptionLicensesByStatus: mockSubscriptionLicensesByStatus, showExpirationNotifications: false, }; if (isBFFQueryEnabled) { mockSubscriptionsDataWithLicense.subscriptionLicensesByStatus = mockSubscriptionLicensesByStatus; - delete mockSubscriptionsDataWithLicense.licensesByStatus; resolveBFFQuery.mockReturnValue(queryEnterpriseLearnerDashboardBFF); fetchEnterpriseLearnerDashboard.mockResolvedValue({ enterpriseCustomerUserSubsidies: { @@ -131,9 +119,8 @@ describe('useSubscriptions', () => { const expectedSubscriptionsdata = { ...mockSubscriptionsDataWithLicense, - licensesByStatus: mockSubscriptionLicensesByStatus, + subscriptionLicensesByStatus: mockSubscriptionLicensesByStatus, }; - delete expectedSubscriptionsdata.subscriptionLicensesByStatus; if (hasQueryOptions && isBFFQueryEnabled) { expect(mockSelect).toHaveBeenCalledWith({ diff --git a/src/components/app/data/services/subsidies/subscriptions.js b/src/components/app/data/services/subsidies/subscriptions.js index 999f43992..8fad2cedc 100644 --- a/src/components/app/data/services/subsidies/subscriptions.js +++ b/src/components/app/data/services/subsidies/subscriptions.js @@ -8,6 +8,7 @@ import { features } from '../../../../../config'; import { LICENSE_STATUS } from '../../../../enterprise-user-subsidy/data/constants'; import { fetchPaginatedData } from '../utils'; import { getBaseSubscriptionsData, SESSION_STORAGE_KEY_LICENSE_ACTIVATION_MESSAGE } from '../../constants'; +import { addLicenseToSubscriptionLicensesByStatus } from '../../utils'; // Subscriptions @@ -134,7 +135,7 @@ export async function activateOrAutoApplySubscriptionLicense({ const { customerAgreement, - licensesByStatus, + subscriptionLicensesByStatus, } = subscriptionsData; // If there is no available customer agreement for the current customer, // or if there is no *current* plan available within such a customer agreement, @@ -151,9 +152,9 @@ export async function activateOrAutoApplySubscriptionLicense({ isCurrentSubscriptionLicenseFilter, ).length > 0; - const hasActivatedSubscriptionLicense = filterLicenseStatus(licensesByStatus[LICENSE_STATUS.ACTIVATED]); - const hasRevokedSubscriptionLicense = filterLicenseStatus(licensesByStatus[LICENSE_STATUS.REVOKED]); - const subscriptionLicenseToActivate = licensesByStatus[LICENSE_STATUS.ASSIGNED].filter( + const hasActivatedSubscriptionLicense = filterLicenseStatus(subscriptionLicensesByStatus[LICENSE_STATUS.ACTIVATED]); + const hasRevokedSubscriptionLicense = filterLicenseStatus(subscriptionLicensesByStatus[LICENSE_STATUS.REVOKED]); + const subscriptionLicenseToActivate = subscriptionLicensesByStatus[LICENSE_STATUS.ASSIGNED].filter( isCurrentSubscriptionLicenseFilter, )[0]; @@ -185,35 +186,21 @@ export async function activateOrAutoApplySubscriptionLicense({ return activatedOrAutoAppliedLicense; } -export function transformSubscriptionsData(subscriptions, options = {}) { - const { isBFFData } = options; - const { baseSubscriptionsData, baseLicensesByStatus } = getBaseSubscriptionsData(); - - const { - customerAgreement, - subscriptionLicenses, - subscriptionLicensesByStatus, - } = subscriptions; - - const licensesByStatus = isBFFData && subscriptionLicensesByStatus - ? subscriptionLicensesByStatus - : baseLicensesByStatus; - - const subscriptionsData = { - ...baseSubscriptionsData, - licensesByStatus, - }; +export function transformSubscriptionsData({ customerAgreement, subscriptionLicenses }) { + const { baseSubscriptionsData } = getBaseSubscriptionsData(); + const subscriptionsData = { ...baseSubscriptionsData }; if (subscriptionLicenses) { subscriptionsData.subscriptionLicenses = subscriptionLicenses; } - if (customerAgreement) { subscriptionsData.customerAgreement = customerAgreement; } + subscriptionsData.showExpirationNotifications = !( customerAgreement?.disableExpirationNotifications || customerAgreement?.hasCustomLicenseExpirationMessagingV2 ); + // Sort licenses within each license status by whether the associated subscription plans // are current; current plans should be prioritized over non-current plans. subscriptionsData.subscriptionLicenses = [...subscriptionLicenses].sort((a, b) => { @@ -226,30 +213,30 @@ export function transformSubscriptionsData(subscriptions, options = {}) { }); // Group licenses by status. - if (!isBFFData) { - subscriptionsData.subscriptionLicenses.forEach((license) => { - const { subscriptionPlan, status } = license; - const isUnassignedLicense = status === LICENSE_STATUS.UNASSIGNED; - if (isUnassignedLicense || !subscriptionPlan.isActive) { - return; - } - subscriptionsData.licensesByStatus[license.status].push(license); + subscriptionsData.subscriptionLicenses.forEach((license) => { + if (license.status === LICENSE_STATUS.UNASSIGNED) { + return; + } + const updatedLicensesByStatus = addLicenseToSubscriptionLicensesByStatus({ + subscriptionLicensesByStatus: subscriptionsData.subscriptionLicensesByStatus, + subscriptionLicense: license, }); - } + subscriptionsData.subscriptionLicensesByStatus = updatedLicensesByStatus; + }); // Extracts a single subscription license for the user, from the ordered licenses by status. - const applicableSubscriptionLicense = Object.values( - subscriptionsData.licensesByStatus, - ).flat()[0]; + const applicableSubscriptionLicense = Object.values(subscriptionsData.subscriptionLicensesByStatus).flat()[0]; if (applicableSubscriptionLicense) { subscriptionsData.subscriptionLicense = applicableSubscriptionLicense; subscriptionsData.subscriptionPlan = applicableSubscriptionLicense.subscriptionPlan; } + + // Return the transformed subscriptions data. return subscriptionsData; } /** - * TODO + * Fetches subscriptions data for the enterprise customer * @returns * @param enterpriseUUID */ @@ -266,24 +253,21 @@ export async function fetchSubscriptions(enterpriseUUID) { * applicable license for use by the rest of the application. * * Example: an activated license will be chosen as the applicable license because activated licenses - * come first in ``licensesByStatus`` even if the user also has a revoked license. + * come first in ``subscriptionLicensesByStatus`` even if the user also has a revoked license. */ - const { baseSubscriptionsData } = getBaseSubscriptionsData(); try { const { results: subscriptionLicenses, response, } = await fetchPaginatedData(url); const { customerAgreement } = response; - const subscriptionsData = { + return transformSubscriptionsData({ customerAgreement, subscriptionLicenses, - }; - return transformSubscriptionsData( - subscriptionsData, - ); + }); } catch (error) { logError(error); + const { baseSubscriptionsData } = getBaseSubscriptionsData(); return baseSubscriptionsData; } } diff --git a/src/components/app/data/services/subsidies/subscriptions.test.js b/src/components/app/data/services/subsidies/subscriptions.test.js index e212ded39..1cbb4fd76 100644 --- a/src/components/app/data/services/subsidies/subscriptions.test.js +++ b/src/components/app/data/services/subsidies/subscriptions.test.js @@ -6,7 +6,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { activateOrAutoApplySubscriptionLicense, fetchSubscriptions } from '.'; import { LICENSE_STATUS } from '../../../../enterprise-user-subsidy/data/constants'; -import { hasValidStartExpirationDates } from '../../../../../utils/common'; +import { getBaseSubscriptionsData } from '../../constants'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -57,10 +57,13 @@ describe('fetchSubscriptions', () => { beforeEach(() => { jest.clearAllMocks(); }); + afterEach(() => { + axiosMock.reset(); + }); it.each([ + // Activated license with current subscription plan { licenseStatus: LICENSE_STATUS.ACTIVATED, - isSubscriptionPlanActive: true, isSubscriptionPlanCurrent: true, daysUntilExpiration: 30, startDate: dayjs().subtract(15, 'days').toISOString(), @@ -69,20 +72,9 @@ describe('fetchSubscriptions', () => { hasCustomLicenseExpirationMessagingV2: false, expectedShowExpirationNotifications: true, }, + // Activated license with expired subscription plan { licenseStatus: LICENSE_STATUS.ACTIVATED, - isSubscriptionPlanActive: false, - isSubscriptionPlanCurrent: true, - daysUntilExpiration: 30, - startDate: dayjs().subtract(15, 'days').toISOString(), - expirationDate: dayjs().add(30, 'days').toISOString(), - disableExpirationNotifications: false, - hasCustomLicenseExpirationMessagingV2: false, - expectedShowExpirationNotifications: true, - }, - { - licenseStatus: LICENSE_STATUS.ACTIVATED, - isSubscriptionPlanActive: true, isSubscriptionPlanCurrent: false, daysUntilExpiration: 0, startDate: dayjs().subtract(15, 'days').toISOString(), @@ -91,9 +83,9 @@ describe('fetchSubscriptions', () => { hasCustomLicenseExpirationMessagingV2: false, expectedShowExpirationNotifications: true, }, + // Unassigned license (should be ignored) { licenseStatus: LICENSE_STATUS.UNASSIGNED, - isSubscriptionPlanActive: true, isSubscriptionPlanCurrent: true, daysUntilExpiration: 30, startDate: dayjs().subtract(15, 'days').toISOString(), @@ -105,7 +97,6 @@ describe('fetchSubscriptions', () => { // Custom subs messaging with standard expiration still enabled { licenseStatus: LICENSE_STATUS.ACTIVATED, - isSubscriptionPlanActive: true, isSubscriptionPlanCurrent: false, daysUntilExpiration: -10, startDate: dayjs().subtract(15, 'days').toISOString(), @@ -117,7 +108,6 @@ describe('fetchSubscriptions', () => { // Disabled standard expiration, with custom subs expiration enabled { licenseStatus: LICENSE_STATUS.ACTIVATED, - isSubscriptionPlanActive: true, isSubscriptionPlanCurrent: false, daysUntilExpiration: -10, startDate: dayjs().subtract(15, 'days').toISOString(), @@ -129,7 +119,6 @@ describe('fetchSubscriptions', () => { // Disabled standard expiration, no custom subs expiration { licenseStatus: LICENSE_STATUS.ACTIVATED, - isSubscriptionPlanActive: true, isSubscriptionPlanCurrent: false, daysUntilExpiration: -10, startDate: dayjs().subtract(15, 'days').toISOString(), @@ -140,7 +129,6 @@ describe('fetchSubscriptions', () => { }, ])('returns subscriptions (%s)', async ({ licenseStatus, - isSubscriptionPlanActive, isSubscriptionPlanCurrent, daysUntilExpiration, startDate, @@ -154,7 +142,7 @@ describe('fetchSubscriptions', () => { status: licenseStatus, subscriptionPlan: { uuid: 'test-subscription-plan-uuid', - isActive: isSubscriptionPlanActive, + isActive: true, isCurrent: isSubscriptionPlanCurrent, daysUntilExpiration, startDate, @@ -177,38 +165,22 @@ describe('fetchSubscriptions', () => { const SUBSCRIPTIONS_URL = `${APP_CONFIG.LICENSE_MANAGER_URL}/api/v1/learner-licenses/?${queryParams.toString()}`; axiosMock.onGet(SUBSCRIPTIONS_URL).reply(200, mockResponse); const response = await fetchSubscriptions(mockEnterpriseId); - const expectedLicensesByStatus = { - [LICENSE_STATUS.ACTIVATED]: [], - [LICENSE_STATUS.ASSIGNED]: [], - [LICENSE_STATUS.REVOKED]: [], - }; - const isLicenseApplicable = ( - licenseStatus !== LICENSE_STATUS.UNASSIGNED - && isSubscriptionPlanActive - ); - const updatedMockSubscriptionLicense = { - ...mockSubscriptionLicense, - subscriptionPlan: { - ...mockSubscriptionLicense.subscriptionPlan, - isCurrent: hasValidStartExpirationDates({ startDate, expirationDate }), - }, - }; - if (isLicenseApplicable) { - expectedLicensesByStatus[licenseStatus].push(updatedMockSubscriptionLicense); - } - const updatedCustomerAgreement = { - customerAgreement: { ...mockResponse.customerAgreement }, - results: [updatedMockSubscriptionLicense], - }; + const { baseLicensesByStatus } = getBaseSubscriptionsData(); + const expectedLicensesByStatus = { ...baseLicensesByStatus }; + const isValidLicenseStatus = licenseStatus !== LICENSE_STATUS.UNASSIGNED; + if (isValidLicenseStatus) { + expectedLicensesByStatus[licenseStatus].push(mockSubscriptionLicense); + } const expectedResult = { - customerAgreement: updatedCustomerAgreement.customerAgreement, - licensesByStatus: expectedLicensesByStatus, - subscriptionPlan: isLicenseApplicable ? updatedMockSubscriptionLicense.subscriptionPlan : null, - subscriptionLicense: isLicenseApplicable ? updatedMockSubscriptionLicense : null, - subscriptionLicenses: [updatedMockSubscriptionLicense], + customerAgreement: mockResponse.customerAgreement, + subscriptionLicensesByStatus: expectedLicensesByStatus, + subscriptionPlan: isValidLicenseStatus ? mockSubscriptionLicense.subscriptionPlan : null, + subscriptionLicense: isValidLicenseStatus ? mockSubscriptionLicense : null, + subscriptionLicenses: [mockSubscriptionLicense], showExpirationNotifications: expectedShowExpirationNotifications, }; + expect(response).toEqual(expectedResult); }); @@ -265,7 +237,7 @@ describe('fetchSubscriptions', () => { const expectedResult = { customerAgreement: mockResponse.customerAgreement, - licensesByStatus: expectedLicensesByStatus, + subscriptionLicensesByStatus: expectedLicensesByStatus, subscriptionPlan: mockSubscriptionLicenseCurrent.subscriptionPlan, subscriptionLicense: mockSubscriptionLicenseCurrent, subscriptionLicenses: [mockSubscriptionLicenseCurrent, mockSubscriptionLicenseRenewal], @@ -315,20 +287,19 @@ describe('activateOrAutoApplySubscriptionLicense', () => { }); it('returns null with already activated license', async () => { + const mockSubscriptionLicense = { + uuid: 'test-license-uuid', + status: LICENSE_STATUS.ACTIVATED, + subscriptionPlan: { isCurrent: true }, + }; + const { baseLicensesByStatus } = getBaseSubscriptionsData(); const mockLicensesByStatus = { - [LICENSE_STATUS.ACTIVATED]: [ - { - uuid: 'test-license-uuid', - status: LICENSE_STATUS.ACTIVATED, - subscriptionPlan: { isCurrent: true }, - }, - ], - [LICENSE_STATUS.ASSIGNED]: [], - [LICENSE_STATUS.REVOKED]: [], + ...baseLicensesByStatus, + [LICENSE_STATUS.ACTIVATED]: [mockSubscriptionLicense], }; const mockSubscriptionsData = { customerAgreement: mockCustomerAgreement, - licensesByStatus: mockLicensesByStatus, + subscriptionLicensesByStatus: mockLicensesByStatus, }; const result = await activateOrAutoApplySubscriptionLicense({ enterpriseCustomer: mockEnterpriseCustomer, @@ -357,7 +328,7 @@ describe('activateOrAutoApplySubscriptionLicense', () => { }; const mockSubscriptionsData = { customerAgreement: mockCustomerAgreement, - licensesByStatus: mockLicensesByStatus, + subscriptionLicensesByStatus: mockLicensesByStatus, }; const result = await activateOrAutoApplySubscriptionLicense({ enterpriseCustomer: mockEnterpriseCustomer, @@ -393,7 +364,7 @@ describe('activateOrAutoApplySubscriptionLicense', () => { }; const mockSubscriptionsData = { customerAgreement: mockCustomerAgreement, - licensesByStatus: mockLicensesByStatus, + subscriptionLicensesByStatus: mockLicensesByStatus, }; axiosMock.onPost(ACTIVATE_LICENSE_URL).reply(200, {}); const mockRequestUrl = { @@ -512,7 +483,7 @@ describe('activateOrAutoApplySubscriptionLicense', () => { }; const mockSubscriptionsData = { customerAgreement: mockCustomerAgreementWithAutoApplied, - licensesByStatus: mockLicensesByStatus, + subscriptionLicensesByStatus: mockLicensesByStatus, subscriptionLicense: { subscriptionPlan: { isCurrent: true, diff --git a/src/components/app/data/utils.js b/src/components/app/data/utils.js index 9246382e5..8cea02cae 100644 --- a/src/components/app/data/utils.js +++ b/src/components/app/data/utils.js @@ -920,3 +920,20 @@ export function isBFFEnabled(enterpriseCustomerUuid, enterpriseFeatures) { // Otherwise, BFF is not enabled. return false; } + +/** + * Adds a subscription license to the subscription licenses grouped by status. + * @param {Oject} args + * @param {Object} args.subscriptionLicensesByStatus - The subscription licenses grouped by status. + * @param {Object} args.subscriptionLicense - The subscription license to add to the subscription licenses by status. + * @returns {Object} - Returns the updated subscription licenses grouped by status. + */ +export function addLicenseToSubscriptionLicensesByStatus({ subscriptionLicensesByStatus, subscriptionLicense }) { + const licenseStatus = subscriptionLicense.status; + const updatedLicensesByStatus = { ...subscriptionLicensesByStatus }; + if (!updatedLicensesByStatus[licenseStatus]) { + updatedLicensesByStatus[licenseStatus] = []; + } + updatedLicensesByStatus[licenseStatus].push(subscriptionLicense); + return updatedLicensesByStatus; +} diff --git a/src/components/app/routes/data/utils.js b/src/components/app/routes/data/utils.js index eb60ecbcf..5ec90b288 100644 --- a/src/components/app/routes/data/utils.js +++ b/src/components/app/routes/data/utils.js @@ -16,6 +16,7 @@ import Cookies from 'universal-cookie'; import { activateOrAutoApplySubscriptionLicense, + addLicenseToSubscriptionLicensesByStatus, queryBrowseAndRequestConfiguration, queryContentHighlightsConfiguration, queryCouponCodeRequests, @@ -60,9 +61,9 @@ export async function ensureEnterpriseAppData({ if (!matchedBFFQuery) { const subscriptionsQuery = querySubscriptions(enterpriseCustomer.uuid); enterpriseAppDataQueries.push( - // Enterprise Customer User Subsidies + // Enterprise Customer User Subsidies queryClient.ensureQueryData(subscriptionsQuery).then(async (subscriptionsData) => { - // Auto-activate the user's subscription license, if applicable. + // Auto-activate the user's subscription license, if applicable. const activatedOrAutoAppliedLicense = await activateOrAutoApplySubscriptionLicense({ enterpriseCustomer, allLinkedEnterpriseCustomerUsers, @@ -70,36 +71,40 @@ export async function ensureEnterpriseAppData({ requestUrl, }); if (activatedOrAutoAppliedLicense) { - const { licensesByStatus } = subscriptionsData; - const updatedLicensesByStatus = { ...licensesByStatus }; - Object.entries(licensesByStatus).forEach(([status, licenses]) => { - const licensesIncludesActivatedOrAutoAppliedLicense = licenses.some( - (license) => license.uuid === activatedOrAutoAppliedLicense.uuid, - ); - const isCurrentStatusMatchingLicenseStatus = status === activatedOrAutoAppliedLicense.status; - if (licensesIncludesActivatedOrAutoAppliedLicense) { - updatedLicensesByStatus[status] = isCurrentStatusMatchingLicenseStatus - ? licenses.filter((license) => license.uuid !== activatedOrAutoAppliedLicense.uuid) - : [...licenses, activatedOrAutoAppliedLicense]; - } else if (isCurrentStatusMatchingLicenseStatus) { - updatedLicensesByStatus[activatedOrAutoAppliedLicense.status].push(activatedOrAutoAppliedLicense); - } + const { subscriptionLicensesByStatus, subscriptionLicenses } = subscriptionsData; + // Create a deep copy of the structure using .map for immutability, removing + // the `activatedOrAutoAppliedLicense` from each list. Then, re-add the license + // to the correct status list. + const licensesByStatusWithoutExistingLicense = Object.fromEntries( + Object.entries(subscriptionLicensesByStatus).map(([key, licenses]) => [ + key, + licenses.filter( + (existingLicense) => existingLicense.uuid !== activatedOrAutoAppliedLicense.uuid, + ), // Remove license immutably + ]), + ); + const updatedLicensesByStatus = addLicenseToSubscriptionLicensesByStatus({ + subscriptionLicensesByStatus: licensesByStatusWithoutExistingLicense, + subscriptionLicense: activatedOrAutoAppliedLicense, }); - // Optimistically update the query cache with the auto-activated or auto-applied subscription license. - const updatedSubscriptionLicenses = subscriptionsData.subscriptionLicenses.length > 0 - ? subscriptionsData.subscriptionLicenses.map((license) => { - // Ensures an auto-activated license is updated in the query cache to change - // its status from "assigned" to "activated". - if (license.uuid === activatedOrAutoAppliedLicense.uuid) { - return activatedOrAutoAppliedLicense; - } - return license; - }) - : [activatedOrAutoAppliedLicense]; + // Update the flat subscription licenses list + const updatedSubscriptionLicenses = [...subscriptionLicenses]; + const licenseIndex = subscriptionLicenses.findIndex( + (license) => license.uuid === activatedOrAutoAppliedLicense.uuid, + ); + if (licenseIndex >= 0) { + // Replace the existing license + updatedSubscriptionLicenses[licenseIndex] = activatedOrAutoAppliedLicense; + } else { + // Add the new license + updatedSubscriptionLicenses.push(activatedOrAutoAppliedLicense); + } + + // Optimistically update the query cache with the auto-activated or auto-applied subscription license. queryClient.setQueryData(subscriptionsQuery.queryKey, { ...queryClient.getQueryData(subscriptionsQuery.queryKey), - licensesByStatus: updatedLicensesByStatus, + subscriptionLicensesByStatus: updatedLicensesByStatus, subscriptionPlan: activatedOrAutoAppliedLicense.subscriptionPlan, subscriptionLicense: activatedOrAutoAppliedLicense, subscriptionLicenses: updatedSubscriptionLicenses, diff --git a/src/components/app/routes/loaders/tests/rootLoader.test.jsx b/src/components/app/routes/loaders/tests/rootLoader.test.jsx index 59f6f1744..444e9ca2e 100644 --- a/src/components/app/routes/loaders/tests/rootLoader.test.jsx +++ b/src/components/app/routes/loaders/tests/rootLoader.test.jsx @@ -6,7 +6,9 @@ import { renderWithRouterProvider } from '../../../../../utils/tests'; import makeRootLoader from '../rootLoader'; import { ensureAuthenticatedUser } from '../../data'; import { + activateOrAutoApplySubscriptionLicense, extractEnterpriseCustomer, + getBaseSubscriptionsData, queryBrowseAndRequestConfiguration, queryContentHighlightsConfiguration, queryCouponCodeRequests, @@ -19,6 +21,8 @@ import { updateUserActiveEnterprise, } from '../../../data'; import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../../data/services/data/__factories__'; +import { isBFFEnabled } from '../../../data/utils'; +import { LICENSE_STATUS } from '../../../../enterprise-user-subsidy/data/constants'; jest.mock('../../data', () => ({ ...jest.requireActual('../../data'), @@ -28,6 +32,11 @@ jest.mock('../../../data', () => ({ ...jest.requireActual('../../../data'), extractEnterpriseCustomer: jest.fn(), updateUserActiveEnterprise: jest.fn(), + activateOrAutoApplySubscriptionLicense: jest.fn(), +})); +jest.mock('../../../data/utils', () => ({ + ...jest.requireActual('../../../data/utils'), + isBFFEnabled: jest.fn(), })); const mockAuthenticatedUser = authenticatedUserFactory(); @@ -36,6 +45,8 @@ const mockEnterpriseCustomerTwo = enterpriseCustomerFactory(); const mockQueryClient = { ensureQueryData: jest.fn().mockResolvedValue(), + getQueryData: jest.fn(), + setQueryData: jest.fn(), }; describe('rootLoader', () => { @@ -86,37 +97,55 @@ describe('rootLoader', () => { expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(1); }); - // TODO: include tests related to resolveBFFQuery within ensureEnterpriseAppData it.each([ + // BFF disabled, non-staff user is linked to requested customer, resolves + // requested customer, does not need to update active enterprise, does not + // need to activate subscription license { enterpriseSlug: mockEnterpriseCustomerTwo.slug, enterpriseCustomer: mockEnterpriseCustomerTwo, - activeEnterpriseCustomer: mockEnterpriseCustomer, + activeEnterpriseCustomer: mockEnterpriseCustomerTwo, allLinkedEnterpriseCustomerUsers: [ { enterpriseCustomer: mockEnterpriseCustomer }, { enterpriseCustomer: mockEnterpriseCustomerTwo }, ], isStaffUser: false, + shouldActivateSubscriptionLicense: false, + hasResolvedBFFQuery: false, }, + // BFF disabled, non-staff user is linked to requested customer, resolves + // requested customer, does not need to update active enterprise, needs + // to activate subscription license { enterpriseSlug: mockEnterpriseCustomerTwo.slug, enterpriseCustomer: mockEnterpriseCustomerTwo, - activeEnterpriseCustomer: mockEnterpriseCustomer, + activeEnterpriseCustomer: mockEnterpriseCustomerTwo, allLinkedEnterpriseCustomerUsers: [ { enterpriseCustomer: mockEnterpriseCustomer }, { enterpriseCustomer: mockEnterpriseCustomerTwo }, ], isStaffUser: false, + shouldActivateSubscriptionLicense: true, + hasResolvedBFFQuery: false, }, + // BFF disabled, non-staff user is linked to requested customer, resolves + // requested customer, needs update to active enterprise, does not + // need to activate subscription license { enterpriseSlug: mockEnterpriseCustomerTwo.slug, - enterpriseCustomer: mockEnterpriseCustomer, + enterpriseCustomer: mockEnterpriseCustomerTwo, activeEnterpriseCustomer: mockEnterpriseCustomer, allLinkedEnterpriseCustomerUsers: [ { enterpriseCustomer: mockEnterpriseCustomer }, + { enterpriseCustomer: mockEnterpriseCustomerTwo }, ], isStaffUser: false, + shouldActivateSubscriptionLicense: false, + hasResolvedBFFQuery: false, }, + // BFF disabled, non-staff user is not linked to requested customer, resolves + // linked customer, does not need to update active enterprise, does not + // need to activate subscription license { enterpriseSlug: mockEnterpriseCustomerTwo.slug, enterpriseCustomer: mockEnterpriseCustomer, @@ -125,7 +154,12 @@ describe('rootLoader', () => { { enterpriseCustomer: mockEnterpriseCustomer }, ], isStaffUser: false, + shouldActivateSubscriptionLicense: false, + hasResolvedBFFQuery: false, }, + // BFF disabled, staff user is not linked to requested customer, resolves + // requested customer, does not need to update active enterprise, does not + // need to activate subscription license { enterpriseSlug: mockEnterpriseCustomerTwo.slug, enterpriseCustomer: mockEnterpriseCustomerTwo, @@ -134,15 +168,38 @@ describe('rootLoader', () => { { enterpriseCustomer: mockEnterpriseCustomer }, ], isStaffUser: true, + shouldActivateSubscriptionLicense: false, + hasResolvedBFFQuery: false, }, + // BFF disabled, staff user is linked to requested customer, resolves + // requested customer, needs update to active enterprise, does not + // need to activate subscription license { enterpriseSlug: mockEnterpriseCustomerTwo.slug, enterpriseCustomer: mockEnterpriseCustomerTwo, activeEnterpriseCustomer: mockEnterpriseCustomer, allLinkedEnterpriseCustomerUsers: [ { enterpriseCustomer: mockEnterpriseCustomer }, + { enterpriseCustomer: mockEnterpriseCustomerTwo }, ], isStaffUser: true, + shouldActivateSubscriptionLicense: false, + hasResolvedBFFQuery: false, + }, + // BFF enabled, non-staff user is linked to requested customer, resolves + // requested customer, needs update to active enterprise, does not + // need to activate subscription license + { + enterpriseSlug: mockEnterpriseCustomerTwo.slug, + enterpriseCustomer: mockEnterpriseCustomerTwo, + activeEnterpriseCustomer: mockEnterpriseCustomer, + allLinkedEnterpriseCustomerUsers: [ + { enterpriseCustomer: mockEnterpriseCustomer }, + { enterpriseCustomer: mockEnterpriseCustomerTwo }, + ], + isStaffUser: false, + shouldActivateSubscriptionLicense: false, + hasResolvedBFFQuery: true, }, ])('ensures all requisite root loader queries are resolved with an active enterprise customer user (%s)', async ({ isStaffUser, @@ -150,7 +207,12 @@ describe('rootLoader', () => { enterpriseCustomer, activeEnterpriseCustomer, allLinkedEnterpriseCustomerUsers, + shouldActivateSubscriptionLicense, + hasResolvedBFFQuery, }) => { + // Mock whether BFF enabled for enterprise customer and/or user + isBFFEnabled.mockReturnValue(hasResolvedBFFQuery); + const enterpriseLearnerQuery = queryEnterpriseLearner(mockAuthenticatedUser.username, enterpriseSlug); const enterpriseLearnerQueryTwo = queryEnterpriseLearner(mockAuthenticatedUser.username, enterpriseCustomer.slug); @@ -191,10 +253,33 @@ describe('rootLoader', () => { ).mockResolvedValue(mockRedeemablePolicies); // Mock subscriptions query - const mockSubscriptionsData = { - customerAgreement: null, - licensesByStatus: {}, - }; + const { baseSubscriptionsData, baseLicensesByStatus } = getBaseSubscriptionsData(); + const mockSubscriptionsData = { ...baseSubscriptionsData }; + if (shouldActivateSubscriptionLicense) { + const mockAssignedLicense = { + uuid: 'assigned-license-uuid', + status: LICENSE_STATUS.ASSIGNED, + activationKey: 'assigned-license-activation-key', + subscriptionPlan: { + uuid: 'subscription-plan-uuid', + isCurrent: true, + }, + }; + mockSubscriptionsData.customerAgreement = { + uuid: 'customer-agreement-uuid', + netDaysUntilExpiration: 30, + }; + mockSubscriptionsData.subscriptionLicenses = [mockAssignedLicense]; + mockSubscriptionsData.subscriptionLicensesByStatus[LICENSE_STATUS.ASSIGNED] = [mockAssignedLicense]; + mockSubscriptionsData.subscriptionLicense = mockAssignedLicense; + mockSubscriptionsData.subscriptionPlan = mockAssignedLicense.subscriptionPlan; + + // Mock the `activateOrAutoApplySubscriptionLicense` mutation + activateOrAutoApplySubscriptionLicense.mockResolvedValue({ + ...mockAssignedLicense, + status: LICENSE_STATUS.ACTIVATED, + }); + } const subscriptionsQuery = querySubscriptions(enterpriseCustomer.uuid); when(mockQueryClient.ensureQueryData).calledWith( expect.objectContaining({ @@ -214,15 +299,15 @@ describe('rootLoader', () => { await waitFor(() => { // Assert that the expected number of queries were made. + let expectedQueryCount = 9; if (enterpriseSlug !== activeEnterpriseCustomer.slug) { - if (isLinked || isStaffUser) { - expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(9); - } else { - expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(2); + if (!(isLinked || isStaffUser)) { + expectedQueryCount = 2; } - } else { - expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(9); + } else if (hasResolvedBFFQuery) { + expectedQueryCount = 8; } + expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(expectedQueryCount); }); // Enterprise learner query @@ -249,13 +334,48 @@ describe('rootLoader', () => { }), ); - // Subscriptions query - expect(mockQueryClient.ensureQueryData).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: subscriptionsQuery.queryKey, - queryFn: expect.any(Function), - }), - ); + // Subscriptions query (only called with BFF disabled) + if (!hasResolvedBFFQuery) { + expect(mockQueryClient.ensureQueryData).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: subscriptionsQuery.queryKey, + queryFn: expect.any(Function), + }), + ); + if (shouldActivateSubscriptionLicense) { + expect(activateOrAutoApplySubscriptionLicense).toHaveBeenCalledTimes(1); + expect(activateOrAutoApplySubscriptionLicense).toHaveBeenCalledWith({ + enterpriseCustomer, + allLinkedEnterpriseCustomerUsers, + subscriptionsData: mockSubscriptionsData, + requestUrl: new URL(`http://localhost/${enterpriseSlug}`), + }); + + // Assert the subscriptions query cache is optimistically updated + expect(mockQueryClient.setQueryData).toHaveBeenCalledWith(subscriptionsQuery.queryKey, { + subscriptionLicenses: [ + { + ...mockSubscriptionsData.subscriptionLicense, + status: LICENSE_STATUS.ACTIVATED, + }, + ], + subscriptionLicensesByStatus: { + ...baseLicensesByStatus, + [LICENSE_STATUS.ACTIVATED]: [ + { + ...mockSubscriptionsData.subscriptionLicense, + status: LICENSE_STATUS.ACTIVATED, + }, + ], + }, + subscriptionLicense: { + ...mockSubscriptionsData.subscriptionLicense, + status: LICENSE_STATUS.ACTIVATED, + }, + subscriptionPlan: mockSubscriptionsData.subscriptionPlan, + }); + } + } // Coupon codes query const couponCodesQuery = queryCouponCodes(enterpriseCustomer.uuid);