diff --git a/src/components/app/data/services/bffs.js b/src/components/app/data/services/bffs.js deleted file mode 100644 index 65b6e0b33a..0000000000 --- a/src/components/app/data/services/bffs.js +++ /dev/null @@ -1,38 +0,0 @@ -import { getConfig } from '@edx/frontend-platform/config'; -import { logError } from '@edx/frontend-platform/logging'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { camelCaseObject } from '@edx/frontend-platform/utils'; - -export const baseLearnerBFFResponse = { - enterpriseCustomerUserSubsidies: { - subscriptions: { - customerAgreement: {}, - subscriptionLicenses: [], - subscriptionLicensesByStatus: {}, - }, - }, - errors: [], - warnings: [], -}; - -export const learnerDashboardBFFResponse = { - ...baseLearnerBFFResponse, - enterpriseCourseEnrollments: [], -}; - -export async function fetchEnterpriseLearnerDashboard(customerIdentifiers) { - const { ENTERPRISE_ACCESS_BASE_URL } = getConfig(); - const url = `${ENTERPRISE_ACCESS_BASE_URL}/api/v1/bffs/learner/dashboard/`; - try { - const params = { - enterprise_customer_uuid: customerIdentifiers?.enterpriseId, - enterprise_customer_slug: customerIdentifiers?.enterpriseSlug, - }; - - const result = await getAuthenticatedHttpClient().post(url, params); - return camelCaseObject(result.data); - } catch (error) { - logError(error); - return learnerDashboardBFFResponse; - } -} diff --git a/src/components/app/data/services/bffs.test.js b/src/components/app/data/services/bffs.test.ts similarity index 64% rename from src/components/app/data/services/bffs.test.js rename to src/components/app/data/services/bffs.test.ts index aced16f1b6..3fe65a4ea7 100644 --- a/src/components/app/data/services/bffs.test.js +++ b/src/components/app/data/services/bffs.test.ts @@ -1,6 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from 'axios'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; import { v4 as uuidv4 } from 'uuid'; import { camelCaseObject } from '@edx/frontend-platform'; @@ -13,15 +14,20 @@ getAuthenticatedHttpClient.mockReturnValue(axios); const APP_CONFIG = { ENTERPRISE_ACCESS_BASE_URL: 'http://localhost:18270', }; + jest.mock('@edx/frontend-platform/config', () => ({ ...jest.requireActual('@edx/frontend-platform'), getConfig: jest.fn(() => APP_CONFIG), })); - jest.mock('@edx/frontend-platform/auth', () => ({ ...jest.requireActual('@edx/frontend-platform/auth'), getAuthenticatedHttpClient: jest.fn(), })); +jest.mock('@edx/frontend-platform/logging', () => ({ + ...jest.requireActual('@edx/frontend-platform/logging'), + logError: jest.fn(), + logInfo: jest.fn(), +})); const mockEnterpriseCustomer = enterpriseCustomerFactory(); const mockCustomerAgreementUuid = uuidv4(); @@ -29,7 +35,8 @@ const mockSubscriptionCatalogUuid = uuidv4(); const mockSubscriptionLicenseUuid = uuidv4(); const mockSubscriptionPlanUuid = uuidv4(); const mockActivationKey = uuidv4(); -const mockBFFDashboardResponse = { + +const mockBaseLearnerBFFResponse = { enterprise_customer_user_subsidies: { subscriptions: { customer_agreement: { @@ -96,6 +103,12 @@ const mockBFFDashboardResponse = { }, }, }, + errors: [], + warnings: [], +}; + +const mockBFFDashboardResponse = { + ...mockBaseLearnerBFFResponse, enterprise_course_enrollments: [ { course_run_id: 'course-v1:edX+DemoX+3T2022', @@ -119,25 +132,82 @@ const mockBFFDashboardResponse = { is_revoked: false, }, ], - errors: [], - warnings: [], }; + describe('fetchEnterpriseLearnerDashboard', () => { - const enterpriseDashboard = `${APP_CONFIG.ENTERPRISE_ACCESS_BASE_URL}/api/v1/bffs/learner/dashboard/`; + const urlForDashboardBFF = `${APP_CONFIG.ENTERPRISE_ACCESS_BASE_URL}/api/v1/bffs/learner/dashboard/`; + beforeEach(() => { jest.clearAllMocks(); axiosMock.reset(); }); - it('returns learner dashboard metadata', async () => { - axiosMock.onPost(enterpriseDashboard).reply(200, mockBFFDashboardResponse); - const result = await fetchEnterpriseLearnerDashboard({ enterpriseId: mockEnterpriseCustomer.uuid }); + it.each([ + { + enterpriseId: mockEnterpriseCustomer.uuid, + enterpriseSlug: undefined, + }, + { + enterpriseId: undefined, + enterpriseSlug: mockEnterpriseCustomer.slug, + }, + { + enterpriseId: mockEnterpriseCustomer.uuid, + enterpriseSlug: mockEnterpriseCustomer.slug, + }, + ])('returns learner dashboard metadata (%s)', async ({ + enterpriseId, + enterpriseSlug, + }) => { + axiosMock.onPost(urlForDashboardBFF).reply(200, mockBFFDashboardResponse); + const result = await fetchEnterpriseLearnerDashboard({ enterpriseId, enterpriseSlug }); expect(result).toEqual(camelCaseObject(mockBFFDashboardResponse)); }); - it('catches error and returns null', async () => { - axiosMock.onPost(enterpriseDashboard).reply(404, learnerDashboardBFFResponse); - const result = await fetchEnterpriseLearnerDashboard(null); + it.each([ + { + enterpriseId: mockEnterpriseCustomer.uuid, + enterpriseSlug: undefined, + }, + { + enterpriseId: undefined, + enterpriseSlug: mockEnterpriseCustomer.slug, + }, + { + enterpriseId: mockEnterpriseCustomer.uuid, + enterpriseSlug: mockEnterpriseCustomer.slug, + }, + { + enterpriseId: undefined, + enterpriseSlug: undefined, + }, + ])('catches error and returns default dashboard BFF response (%s)', async ({ + enterpriseId, + enterpriseSlug, + }) => { + axiosMock.onPost(urlForDashboardBFF).reply(404, learnerDashboardBFFResponse); + const result = await fetchEnterpriseLearnerDashboard({ enterpriseId, enterpriseSlug }); expect(result).toEqual(learnerDashboardBFFResponse); }); + + it('logs errors and warnings from BFF response', async () => { + const mockError = { + developer_message: 'This is a developer message', + }; + const mockWarning = { + developer_message: 'This is a developer warning', + }; + const mockResponseWithErrorsAndWarnings = { + ...mockBFFDashboardResponse, + errors: [mockError], + warnings: [mockWarning], + }; + axiosMock.onPost(urlForDashboardBFF).reply(200, mockResponseWithErrorsAndWarnings); + const result = await fetchEnterpriseLearnerDashboard({ enterpriseSlug: mockEnterpriseCustomer.slug }); + expect(result).toEqual(camelCaseObject(mockResponseWithErrorsAndWarnings)); + + // Assert the logError and logInfo functions were called with the expected arguments. + expect(logError).toHaveBeenCalledWith(`BFF Error (${urlForDashboardBFF}): ${mockError.developer_message}`); + expect(logInfo).toHaveBeenCalledWith(`BFF Warning (${urlForDashboardBFF}): ${mockWarning.developer_message}`); + }); }); diff --git a/src/components/app/data/services/bffs.ts b/src/components/app/data/services/bffs.ts new file mode 100644 index 0000000000..cc1bac8d3a --- /dev/null +++ b/src/components/app/data/services/bffs.ts @@ -0,0 +1,111 @@ +import { getConfig } from '@edx/frontend-platform/config'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { camelCaseObject, snakeCaseObject } from '@edx/frontend-platform/utils'; + +export const baseLearnerBFFResponse = { + enterpriseCustomerUserSubsidies: { + subscriptions: { + customerAgreement: {}, + subscriptionLicenses: [], + subscriptionLicensesByStatus: {}, + }, + }, + errors: [], + warnings: [], +}; + +export const learnerDashboardBFFResponse = { + ...baseLearnerBFFResponse, + enterpriseCourseEnrollments: [], +}; + +/** + * Log any errors and warnings from the BFF response. + * @param {Object} args + * @param {String} args.url - The URL of the BFF API endpoint. + * @param {Object} args.response - The camelCased response from the BFF API endpoint. + */ +export function logErrorsAndWarningsFromBFFResponse({ url, response }) { + response.errors.forEach((error) => { + logError(`BFF Error (${url}): ${error.developerMessage}`); + }); + response.warnings.forEach((warning) => { + logInfo(`BFF Warning (${url}): ${warning.developerMessage}`); + }); +} + +/** + * Make a request to the specified BFF API endpoint. + * @param {Object} args + * @param {String} args.url - The URL of the BFF API endpoint. + * @param {Object} args.defaultResponse - The default response to return if unable to resolve the request. + * @param {Object} args.options - The options to pass to the BFF API endpoint. + * @param {String} [args.options.enterpriseId] - The UUID of the enterprise customer. + * @param {String} [args.options.enterpriseSlug] - The slug of the enterprise customer. + * @returns {Promise} - The response from the BFF. + */ +export async function makeBFFRequest({ + url, + defaultResponse, + options = {} as Types.BFFRequestOptions, +}) { + const { enterpriseId, enterpriseSlug, ...optionsRest } = options; + const snakeCaseOptionsRest = optionsRest ? snakeCaseObject(optionsRest) : {}; + + // If neither enterpriseId or enterpriseSlug is provided, return the default response. + if (!enterpriseId && !enterpriseSlug) { + return defaultResponse; + } + + try { + const params = { + enterprise_customer_uuid: enterpriseId, + enterprise_customer_slug: enterpriseSlug, + ...snakeCaseOptionsRest, + }; + + // Make request to BFF. + const result = await getAuthenticatedHttpClient().post(url, params); + const response = camelCaseObject(result.data); + + // Log any errors and warnings from the BFF response. + logErrorsAndWarningsFromBFFResponse({ url, response }); + + // Return the response from the BFF. + return response; + } catch (error) { + logError(error); + return defaultResponse; + } +} + +export interface EnterpriseLearnerDashboardOptions { + enterpriseId?: string; + enterpriseSlug?: string; +} + +/** + * Fetch the learner dashboard BFF API for the specified enterprise customer. + * @param {Object} args + * @param {String} [args.enterpriseId] - The UUID of the enterprise customer. + * @param {String} [args.enterpriseSlug] - The slug of the enterprise customer. + * @returns {Promise} - The learner dashboard metadata. + */ +export async function fetchEnterpriseLearnerDashboard({ + enterpriseId, + enterpriseSlug, +}: EnterpriseLearnerDashboardOptions) { + const options = {} as Types.BFFRequestOptions; + if (enterpriseId) { + options.enterpriseId = enterpriseId; + } + if (enterpriseSlug) { + options.enterpriseSlug = enterpriseSlug; + } + return makeBFFRequest({ + url: `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/bffs/learner/dashboard/`, + defaultResponse: learnerDashboardBFFResponse, + options, + }); +} diff --git a/src/components/app/data/services/data/__factories__/enterpriseCustomerUser.factory.js b/src/components/app/data/services/data/__factories__/enterpriseCustomerUser.factory.ts similarity index 96% rename from src/components/app/data/services/data/__factories__/enterpriseCustomerUser.factory.js rename to src/components/app/data/services/data/__factories__/enterpriseCustomerUser.factory.ts index cab510dc53..64282a0d57 100644 --- a/src/components/app/data/services/data/__factories__/enterpriseCustomerUser.factory.js +++ b/src/components/app/data/services/data/__factories__/enterpriseCustomerUser.factory.ts @@ -37,7 +37,7 @@ Factory.define('enterpriseCustomer') secondary_color: faker.internet.color(), tertiary_color: faker.internet.color(), }); -export function enterpriseCustomerFactory(overrides = {}) { +export function enterpriseCustomerFactory(overrides = {}): Types.EnterpriseCustomer { return camelCaseObject(Factory.build('enterpriseCustomer', overrides)); } diff --git a/src/components/enterprise-page/EnterprisePage.jsx b/src/components/enterprise-page/EnterprisePage.jsx index b38365fb1d..b4024a549f 100644 --- a/src/components/enterprise-page/EnterprisePage.jsx +++ b/src/components/enterprise-page/EnterprisePage.jsx @@ -7,23 +7,37 @@ import { getLoggingService } from '@edx/frontend-platform/logging'; import { isDefinedAndNotNull } from '../../utils/common'; import { useAlgoliaSearch } from '../../utils/hooks'; import { pushUserCustomerAttributes } from '../../utils/optimizely'; -import { useEnterpriseCustomer } from '../app/data'; +import { isBFFEnabledForEnterpriseCustomer, useEnterpriseCustomer } from '../app/data'; -const EnterprisePage = ({ children }) => { +/** + * Custom hook to set custom attributes for logging service: + * - enterprise_customer_uuid - The UUID of the enterprise customer + * - is_bff_enabled - Whether the BFF is enabled for the enterprise customer + */ +function useLoggingCustomAttributes() { const { data: enterpriseCustomer } = useEnterpriseCustomer(); - const config = getConfig(); - const [searchClient, searchIndex] = useAlgoliaSearch(config); - const { authenticatedUser } = useContext(AppContext); - useEffect(() => { if (isDefinedAndNotNull(enterpriseCustomer)) { pushUserCustomerAttributes(enterpriseCustomer); - // Set custom attributes for logging service + // Set custom attributes via logging service const loggingService = getLoggingService(); loggingService.setCustomAttribute('enterprise_customer_uuid', enterpriseCustomer.uuid); + loggingService.setCustomAttribute( + 'is_bff_enabled', + isBFFEnabledForEnterpriseCustomer(enterpriseCustomer.uuid), + ); } }, [enterpriseCustomer]); +} + +const EnterprisePage = ({ children }) => { + const config = getConfig(); + const [searchClient, searchIndex] = useAlgoliaSearch(config); + const { authenticatedUser } = useContext(AppContext); + + // Set custom attributes via logging service + useLoggingCustomAttributes(); const contextValue = useMemo(() => ({ authenticatedUser, diff --git a/src/components/enterprise-page/EnterprisePage.test.jsx b/src/components/enterprise-page/EnterprisePage.test.jsx index e9f163fc27..29de7aa7a9 100644 --- a/src/components/enterprise-page/EnterprisePage.test.jsx +++ b/src/components/enterprise-page/EnterprisePage.test.jsx @@ -4,12 +4,13 @@ import { AppContext } from '@edx/frontend-platform/react'; import { getLoggingService } from '@edx/frontend-platform/logging'; import EnterprisePage from './EnterprisePage'; -import { useEnterpriseCustomer } from '../app/data'; +import { isBFFEnabledForEnterpriseCustomer, useEnterpriseCustomer } from '../app/data'; import { authenticatedUserFactory, enterpriseCustomerFactory } from '../app/data/services/data/__factories__'; jest.mock('../app/data', () => ({ ...jest.requireActual('../app/data'), useEnterpriseCustomer: jest.fn(), + isBFFEnabledForEnterpriseCustomer: jest.fn().mockReturnValue(false), })); const mockEnterpriseCustomer = enterpriseCustomerFactory(); @@ -30,7 +31,12 @@ describe('', () => { useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); }); - const defaultAppContextValue = { authenticatedUser: mockAuthenticatedUser }; + const defaultAppContextValue = { + authenticatedUser: mockAuthenticatedUser, + config: { + FEATURE_ENABLE_BFF_API_FOR_ENTERPRISE_CUSTOMERS: [], + }, + }; const EnterprisePageWrapper = ({ children, appContextValue = defaultAppContextValue }) => ( @@ -70,4 +76,27 @@ describe('', () => { }), ); }); + + it.each([ + { isBFFEnabled: false }, + { isBFFEnabled: true }, + ])('sets custom attributes via logging service (%s)', ({ isBFFEnabled }) => { + // Mock the BFF feature flag + isBFFEnabledForEnterpriseCustomer.mockReturnValue(isBFFEnabled); + + // Mount the component + const wrapper = mount( + +
+ , + ); + + // Verify the children are rendered + expect(wrapper.find('[data-testid="child-component"]').exists()).toBe(true); + + // Verify that the custom attributes were set + expect(mockSetCustomAttribute).toHaveBeenCalledTimes(2); + expect(mockSetCustomAttribute).toHaveBeenCalledWith('enterprise_customer_uuid', mockEnterpriseCustomer.uuid); + expect(mockSetCustomAttribute).toHaveBeenCalledWith('is_bff_enabled', isBFFEnabled); + }); }); diff --git a/src/types.d.ts b/src/types.d.ts index 094568314d..4a4a0d56f0 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -29,6 +29,14 @@ export interface AuthenticatedUser { administrator: boolean; } +export interface BFFRequestAdditionalOptions { + [key: string]: any; // Allow any additional properties +} + +export type BFFRequestOptions = + | ({ enterpriseId: string; enterpriseSlug?: string; } & BFFRequestAdditionalOptions) + | ({ enterpriseId?: string; enterpriseSlug: string; } & BFFRequestAdditionalOptions); + export interface EnterpriseCustomer { uuid: string; slug: string; @@ -43,7 +51,7 @@ export interface EnterpriseLearnerData { staffEnterpriseCustomer: Types.EnterpriseCustomer; } -interface DueDate { +interface EnrollmentDueDate { name: string; date: string; url: string; @@ -69,7 +77,7 @@ export interface EnterpriseCourseEnrollment { course_run_url: string; resume_course_run_url?: string; is_revoked: boolean; - due_dates: DueDate[]; + due_dates: EnrollmentDueDate[]; } // Application Data (subsidy)