diff --git a/package-lock.json b/package-lock.json index b0cede8..a927e23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@fortawesome/free-solid-svg-icons": "6.3.0", "@fortawesome/react-fontawesome": "^0.2.0", "@reduxjs/toolkit": "1.9.5", + "axios-mock-adapter": "1.21.4", "babel-polyfill": "6.26.0", "classnames": "2.3.2", "lodash": "4.17.21", @@ -23,6 +24,7 @@ "react-responsive": "8.2.0", "react-router-dom": "5.3.4", "react-transition-group": "4.4.5", + "rosie": "2.1.0", "timeago.js": "4.0.2" }, "devDependencies": { @@ -7709,8 +7711,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -7799,7 +7800,6 @@ "version": "0.27.2", "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", - "dev": true, "dependencies": { "follow-redirects": "^1.14.9", "form-data": "^4.0.0" @@ -7819,6 +7819,40 @@ "url": "https://github.com/ArthurFiorette/axios-cache-interceptor?sponsor=1" } }, + "node_modules/axios-mock-adapter": { + "version": "1.21.4", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.21.4.tgz", + "integrity": "sha512-ztnENm28ONAKeRXC/6SUW6pcsaXbThKq93MRDRAA47LYTzrGSDoO/DCr1NHz7jApEl95DrBoGPvZ0r9xtSbjqw==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, + "node_modules/axios-mock-adapter/node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", @@ -9166,7 +9200,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -9896,7 +9929,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -11774,8 +11806,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-defer": { "version": "1.1.7", @@ -12052,7 +12083,6 @@ "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", - "dev": true, "funding": [ { "type": "individual", @@ -12283,7 +12313,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -19203,7 +19232,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -19212,7 +19240,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -22628,6 +22655,14 @@ "rimraf": "bin.js" } }, + "node_modules/rosie": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/rosie/-/rosie-2.1.0.tgz", + "integrity": "sha512-Dbzdc+prLXZuB/suRptDnBUY29SdGvND3bLg6cll8n7PNqzuyCxSlRfrkn8PqjS9n4QVsiM7RCvxCkKAkTQRjA==", + "engines": { + "node": ">=10" + } + }, "node_modules/rst-selector-parser": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", diff --git a/package.json b/package.json index 3c2fa98..c31ec8c 100644 --- a/package.json +++ b/package.json @@ -62,14 +62,16 @@ "@fortawesome/free-solid-svg-icons": "6.3.0", "@fortawesome/react-fontawesome": "^0.2.0", "@reduxjs/toolkit": "1.9.5", + "axios-mock-adapter": "1.21.4", "babel-polyfill": "6.26.0", "classnames": "2.3.2", "lodash": "4.17.21", "react-redux": "7.2.9", "react-responsive": "8.2.0", + "react-router-dom": "5.3.4", "react-transition-group": "4.4.5", - "timeago.js": "4.0.2", - "react-router-dom": "5.3.4" + "rosie": "2.1.0", + "timeago.js": "4.0.2" }, "peerDependencies": { "@edx/frontend-platform": "^4.0.0", diff --git a/src/Notifications/data/__factories__/index.js b/src/Notifications/data/__factories__/index.js new file mode 100644 index 0000000..cdf7f0b --- /dev/null +++ b/src/Notifications/data/__factories__/index.js @@ -0,0 +1 @@ +import './notifications.factory'; diff --git a/src/Notifications/data/__factories__/notifications.factory.js b/src/Notifications/data/__factories__/notifications.factory.js new file mode 100644 index 0000000..f919167 --- /dev/null +++ b/src/Notifications/data/__factories__/notifications.factory.js @@ -0,0 +1,22 @@ +import { Factory } from 'rosie'; + +Factory.define('notificationsCount') + .attr('count', 45) + .attr('countByAppName', { + reminders: 10, + discussions: 20, + grades: 10, + authoring: 5, + }) + .attr('showNotificationTray', true); + +Factory.define('notification') + .sequence('id') + .attr('type', 'post') + .sequence('content', ['id'], (idx, notificationId) => `

User ${idx} posts Hello and welcome to SC0x + ${notificationId}!

`) + .attr('course_name', 'Supply Chain Analytics') + .sequence('content_url', (idx) => `https://example.com/${idx}`) + .attr('last_read', null) + .attr('last_seen', null) + .sequence('created_at', ['createdDate'], (idx, date) => date); diff --git a/src/Notifications/data/api.js b/src/Notifications/data/api.js index 8e7c117..020ef80 100644 --- a/src/Notifications/data/api.js +++ b/src/Notifications/data/api.js @@ -1,40 +1,44 @@ -import { camelCaseObject } from '@edx/frontend-platform'; -import notificationsList from './notifications.json'; +import { getConfig, snakeCaseObject } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +export const getNotificationsCountApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/count/`; +export const getNotificationsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/`; +export const markNotificationsSeenApiUrl = (appName) => `${getConfig().LMS_BASE_URL}/api/notifications/mark-notifications-unseen/${appName}/`; +export const markNotificationAsReadApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/read/`; export async function getNotifications(appName, page, pageSize) { - const { data } = notificationsList; + const params = snakeCaseObject({ page, pageSize }); + const { data } = await getAuthenticatedHttpClient().get(getNotificationsApiUrl(), { params }); + const startIndex = (page - 1) * pageSize; const endIndex = startIndex + pageSize; const notifications = data.slice(startIndex, endIndex); - return { notifications: camelCaseObject(notifications), numPages: 2, currentPage: page }; + return { notifications, numPages: 2, currentPage: page }; } export async function getNotificationCounts() { - const data = { - count: 45, - count_by_app_name: { - reminders: 10, - discussions: 20, - grades: 10, - authoring: 5, - }, - show_notification_tray: false, - }; - return camelCaseObject(data); + const { data } = await getAuthenticatedHttpClient().get(getNotificationsCountApiUrl()); + + return data; } -export async function markNotificationSeen() { - const data = []; - return camelCaseObject(data); +export async function markNotificationSeen(appName) { + const { data } = await getAuthenticatedHttpClient().put(`${markNotificationsSeenApiUrl(appName)}`); + + return data; } -export async function markAllNotificationRead() { - const { data } = camelCaseObject(notificationsList); +export async function markAllNotificationRead(appName) { + const params = snakeCaseObject({ appName }); + const { data } = await getAuthenticatedHttpClient().put(markNotificationAsReadApiUrl(), { params }); + return data; } export async function markNotificationRead(notificationId) { - const { data } = camelCaseObject(notificationsList); + const params = snakeCaseObject({ notificationId }); + const { data } = await getAuthenticatedHttpClient().put(markNotificationAsReadApiUrl(), { params }); + return { data, id: notificationId }; } diff --git a/src/Notifications/data/api.test.js b/src/Notifications/data/api.test.js new file mode 100644 index 0000000..9c0d82a --- /dev/null +++ b/src/Notifications/data/api.test.js @@ -0,0 +1,150 @@ +import MockAdapter from 'axios-mock-adapter'; +import { Factory } from 'rosie'; + +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; + +import { + getNotificationsApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl, + getNotificationCounts, getNotifications, markNotificationSeen, markAllNotificationRead, markNotificationRead, +} from './api'; + +import './__factories__'; + +const notificationCountsApiUrl = getNotificationsCountApiUrl(); +const notificationsApiUrl = getNotificationsApiUrl(); +const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussions'); +const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl(); + +let axiosMock = null; + +describe('Notifications API', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: '123abc', + username: 'testuser', + administrator: false, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + Factory.resetAll(); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + it('Successfully get notification counts for different tabs.', async () => { + axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount'))); + + const { count, countByAppName } = await getNotificationCounts(); + + expect(count).toEqual(45); + expect(countByAppName.reminders).toEqual(10); + expect(countByAppName.discussions).toEqual(20); + expect(countByAppName.grades).toEqual(10); + expect(countByAppName.authoring).toEqual(5); + }); + + it.each([ + { statusCode: 404, message: 'Failed to get notification counts.' }, + { statusCode: 403, message: 'Denied to get notification counts.' }, + ])('%s for notification counts API.', async ({ statusCode, message }) => { + axiosMock.onGet(notificationCountsApiUrl).reply(statusCode, { message }); + try { + await getNotificationCounts(); + } catch (error) { + expect(error.response.status).toEqual(statusCode); + expect(error.response.data.message).toEqual(message); + } + }); + + it('Successfully get notifications.', async () => { + axiosMock.onGet(notificationsApiUrl).reply( + 200, + (Factory.buildList('notification', 2, null, { createdDate: new Date().toISOString() })), + ); + + const { notifications } = await getNotifications('discussions', 1, 10); + + expect(notifications).toHaveLength(2); + }); + + it.each([ + { statusCode: 404, message: 'Failed to get notifications.' }, + { statusCode: 403, message: 'Denied to get notifications.' }, + ])('%s for notification API.', async ({ statusCode, message }) => { + axiosMock.onGet(notificationsApiUrl).reply(statusCode, { message }); + try { + await getNotifications({ page: 1, pageSize: 10 }); + } catch (error) { + expect(error.response.status).toEqual(statusCode); + expect(error.response.data.message).toEqual(message); + } + }); + + it('Successfully marked all notifications as seen for selected app.', async () => { + axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(200, { message: 'Notifications marked seen.' }); + + const { message } = await markNotificationSeen('discussions'); + + expect(message).toEqual('Notifications marked seen.'); + }); + + it.each([ + { statusCode: 404, message: 'Failed to mark all notifications as seen for selected app.' }, + { statusCode: 403, message: 'Denied to mark all notifications as seen for selected app.' }, + ])('%s for notification mark as seen API.', async ({ statusCode, message }) => { + axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(statusCode, { message }); + try { + await markNotificationSeen('discussions'); + } catch (error) { + expect(error.response.status).toEqual(statusCode); + expect(error.response.data.message).toEqual(message); + } + }); + + it('Successfully marked all notifications as read for selected app.', async () => { + axiosMock.onPut(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notifications marked read.' }); + + const { message } = await markAllNotificationRead('discussions'); + + expect(message).toEqual('Notifications marked read.'); + }); + + it.each([ + { statusCode: 404, message: 'Failed to mark all notifications as read for selected app.' }, + { statusCode: 403, message: 'Denied to mark all notifications as read for selected app.' }, + ])('%s for notification mark all as read API.', async ({ statusCode, message }) => { + axiosMock.onPut(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message }); + try { + await markAllNotificationRead('discussions'); + } catch (error) { + expect(error.response.status).toEqual(statusCode); + expect(error.response.data.message).toEqual(message); + } + }); + + it('Successfully marked notification as read.', async () => { + axiosMock.onPut(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notification marked read.' }); + + const { data } = await markNotificationRead(1); + + expect(data.message).toEqual('Notification marked read.'); + }); + + it.each([ + { statusCode: 404, message: 'Failed to mark notification as read.' }, + { statusCode: 403, message: 'Denied to mark notification as read.' }, + ])('%s for notification mark as read API.', async ({ statusCode, message }) => { + axiosMock.onPut(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message }); + try { + await markAllNotificationRead(1); + } catch (error) { + expect(error.response.status).toEqual(statusCode); + expect(error.response.data.message).toEqual(message); + } + }); +}); diff --git a/src/Notifications/data/redux.test.js b/src/Notifications/data/redux.test.js new file mode 100644 index 0000000..f17e6bf --- /dev/null +++ b/src/Notifications/data/redux.test.js @@ -0,0 +1,164 @@ +import MockAdapter from 'axios-mock-adapter'; +import { Factory } from 'rosie'; + +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; + +import { initializeStore } from '../../store'; +import executeThunk from '../../test-utils'; +import { + getNotificationsApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl, +} from './api'; +import { + fetchAppsNotificationCount, fetchNotificationList, markNotificationsAsRead, markAllNotificationsAsRead, + resetNotificationState, markNotificationsAsSeen, +} from './thunks'; + +import './__factories__'; + +const notificationCountsApiUrl = getNotificationsCountApiUrl(); +const notificationsApiUrl = getNotificationsApiUrl(); +const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl(); +const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussions'); + +let axiosMock; +let store; + +describe('Notification Redux', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: '123abc', + username: 'testuser', + administrator: false, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + Factory.resetAll(); + store = initializeStore(); + + axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount'))); + axiosMock.onGet(notificationsApiUrl).reply( + 200, + (Factory.buildList('notification', 2, null, { createdDate: new Date().toISOString() })), + ); + await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState); + await executeThunk(fetchNotificationList({ page: 1, pageSize: 10 }), store.dispatch, store.getState); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + it('Successfully loaded initial notification states in the redux.', async () => { + executeThunk(resetNotificationState(), store.dispatch, store.getState); + + const { notifications } = store.getState(); + + expect(notifications.notificationStatus).toEqual('idle'); + expect(notifications.appName).toEqual('discussions'); + expect(notifications.appsId).toHaveLength(0); + expect(notifications.apps).toEqual({}); + expect(notifications.notifications).toEqual({}); + expect(notifications.tabsCount).toEqual({}); + expect(notifications.showNotificationTray).toEqual(false); + expect(notifications.pagination.count).toEqual(10); + expect(notifications.pagination.numPages).toEqual(1); + expect(notifications.pagination.currentPage).toEqual(1); + expect(notifications.pagination.nextPage).toBeNull(); + }); + + it('Successfully loaded notifications list in the redux.', async () => { + const { notifications: { notifications } } = store.getState(); + + expect(Object.keys(notifications)).toHaveLength(2); + }); + + it.each([ + { statusCode: 404, status: 'failed' }, + { statusCode: 403, status: 'denied' }, + ])('%s to load notifications list in the redux.', async ({ statusCode, status }) => { + axiosMock.onGet(notificationsApiUrl).reply(statusCode); + await executeThunk(fetchNotificationList({ page: 1, pageSize: 10 }), store.dispatch, store.getState); + + const { notifications: { notificationStatus } } = store.getState(); + + expect(notificationStatus).toEqual(status); + }); + + it('Successfully loaded notification counts in the redux.', async () => { + const { notifications: { tabsCount } } = store.getState(); + + expect(tabsCount.count).toEqual(25); + expect(tabsCount.reminders).toEqual(10); + expect(tabsCount.discussions).toEqual(0); + expect(tabsCount.grades).toEqual(10); + expect(tabsCount.authoring).toEqual(5); + }); + + it.each([ + { statusCode: 404, status: 'failed' }, + { statusCode: 403, status: 'denied' }, + ])('%s to load notification counts in the redux.', async ({ statusCode, status }) => { + axiosMock.onGet(notificationCountsApiUrl).reply(statusCode); + await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState); + + const { notifications: { notificationStatus } } = store.getState(); + + expect(notificationStatus).toEqual(status); + }); + + it('Successfully marked all notifications as seen for selected app.', async () => { + axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(200); + await executeThunk(markNotificationsAsSeen('discussions'), store.dispatch, store.getState); + + expect(store.getState().notifications.notificationStatus).toEqual('successful'); + }); + + it.each([ + { statusCode: 404, status: 'failed' }, + { statusCode: 403, status: 'denied' }, + ])('%s to mark all notifications as seen for selected app.', async ({ statusCode, status }) => { + axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(statusCode); + await executeThunk(markNotificationsAsSeen('discussions'), store.dispatch, store.getState); + + const { notifications: { notificationStatus } } = store.getState(); + + expect(notificationStatus).toEqual(status); + }); + + it('Successfully marked all notifications as read for selected app in the redux.', async () => { + axiosMock.onPut(markedAllNotificationsAsReadApiUrl).reply(200); + await executeThunk(markAllNotificationsAsRead('discussions'), store.dispatch, store.getState); + + const { notifications: { notificationStatus, notifications } } = store.getState(); + const firstNotification = Object.values(notifications)[0]; + + expect(notificationStatus).toEqual('successful'); + expect(firstNotification.lastRead).not.toBeNull(); + }); + + it('Successfully marked notification as read in the redux.', async () => { + axiosMock.onPut(markedAllNotificationsAsReadApiUrl).reply(200); + await executeThunk(markNotificationsAsRead(1), store.dispatch, store.getState); + + const { notifications: { notificationStatus, notifications } } = store.getState(); + const firstNotification = Object.values(notifications)[0]; + + expect(notificationStatus).toEqual('successful'); + expect(firstNotification.lastRead).not.toBeNull(); + }); + + it.each([ + { statusCode: 404, status: 'failed' }, + { statusCode: 403, status: 'denied' }, + ])('%s to marked notification as read in the redux.', async ({ statusCode, status }) => { + axiosMock.onPut(markedAllNotificationsAsReadApiUrl).reply(statusCode); + await executeThunk(markNotificationsAsRead(1), store.dispatch, store.getState); + + const { notifications: { notificationStatus } } = store.getState(); + + expect(notificationStatus).toEqual(status); + }); +}); diff --git a/src/Notifications/data/selector.test.jsx b/src/Notifications/data/selector.test.jsx new file mode 100644 index 0000000..bc73a3f --- /dev/null +++ b/src/Notifications/data/selector.test.jsx @@ -0,0 +1,126 @@ +import MockAdapter from 'axios-mock-adapter'; +import { Factory } from 'rosie'; + +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; + +import { initializeStore } from '../../store'; +import executeThunk from '../../test-utils'; +import { getNotificationsApiUrl, getNotificationsCountApiUrl } from './api'; +import { + selectNotifications, + selectNotificationsByIds, + selectNotificationStatus, + selectNotificationTabs, + selectNotificationTabsCount, + selectPaginationData, + selectSelectedAppName, + selectSelectedAppNotificationIds, + selectShowNotificationTray, +} from './selectors'; +import { fetchAppsNotificationCount, fetchNotificationList } from './thunks'; + +import './__factories__'; + +const notificationCountsApiUrl = getNotificationsCountApiUrl(); +const notificationsApiUrl = getNotificationsApiUrl(); + +let axiosMock; +let store; + +describe('Notification Selectors', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: '123abc', + username: 'testuser', + administrator: false, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + Factory.resetAll(); + store = initializeStore(); + + axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount'))); + axiosMock.onGet(notificationsApiUrl).reply( + 200, + (Factory.buildList('notification', 2, null, { createdDate: new Date().toISOString() })), + ); + await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState); + await executeThunk(fetchNotificationList({ page: 1, pageSize: 10 }), store.dispatch, store.getState); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + it('Should return notification status.', async () => { + const state = store.getState(); + const status = selectNotificationStatus()(state); + + expect(status).toEqual('successful'); + }); + + it('Should return notification tabs count.', async () => { + const state = store.getState(); + const tabsCount = selectNotificationTabsCount()(state); + + expect(tabsCount.count).toEqual(25); + expect(tabsCount.reminders).toEqual(10); + expect(tabsCount.discussions).toEqual(0); + expect(tabsCount.grades).toEqual(10); + expect(tabsCount.authoring).toEqual(5); + }); + + it('Should return notification tabs.', async () => { + const state = store.getState(); + const tabs = selectNotificationTabs()(state); + + expect(tabs).toHaveLength(4); + }); + + it('Should return selected app notification ids.', async () => { + const state = store.getState(); + const notificationIds = selectSelectedAppNotificationIds('discussions')(state); + + expect(notificationIds).toHaveLength(2); + }); + + it('Should return show notification tray status.', async () => { + const state = store.getState(); + const showNotificationTrayStatus = selectShowNotificationTray()(state); + + expect(showNotificationTrayStatus).toEqual(true); + }); + + it('Should return notifications.', async () => { + const state = store.getState(); + const notifications = selectNotifications()(state); + + expect(Object.keys(notifications)).toHaveLength(2); + }); + + it('Should return notifications from Ids.', async () => { + const state = store.getState(); + const notifications = selectNotificationsByIds('discussions')(state); + + expect(notifications).toHaveLength(2); + }); + + it('Should return selected app name.', async () => { + const state = store.getState(); + const appName = selectSelectedAppName()(state); + + expect(appName).toEqual('discussions'); + }); + + it('Should return pagination data.', async () => { + const state = store.getState(); + const paginationData = selectPaginationData()(state); + + expect(paginationData.count).toEqual(10); + expect(paginationData.currentPage).toEqual(1); + expect(paginationData.numPages).toEqual(2); + }); +}); diff --git a/src/Notifications/data/thunks.js b/src/Notifications/data/thunks.js index 1e702b2..3ffbb82 100644 --- a/src/Notifications/data/thunks.js +++ b/src/Notifications/data/thunks.js @@ -1,3 +1,4 @@ +import { camelCaseObject } from '@edx/frontend-platform'; import { fetchNotificationSuccess, fetchNotificationRequest, @@ -47,7 +48,7 @@ export const fetchNotificationList = ({ appName, page, pageSize }) => ( try { dispatch(fetchNotificationRequest({ appName })); const data = await getNotifications(appName, page, pageSize); - const normalisedData = normalizeNotifications((data)); + const normalisedData = normalizeNotifications((camelCaseObject(data))); dispatch(fetchNotificationSuccess({ ...normalisedData, numPages: data.numPages, currentPage: data.currentPage })); } catch (error) { if (getHttpErrorStatus(error) === 403) { @@ -64,7 +65,7 @@ export const fetchAppsNotificationCount = () => ( try { dispatch(fetchNotificationsCountRequest()); const data = await getNotificationCounts(); - const normalisedData = normalizeNotificationCounts((data)); + const normalisedData = normalizeNotificationCounts((camelCaseObject(data))); dispatch(fetchNotificationsCountSuccess({ ...normalisedData, countByAppName: data.countByAppName, @@ -86,7 +87,7 @@ export const markAllNotificationsAsRead = (appName) => ( try { dispatch(markAllNotificationsAsReadRequest({ appName })); const data = await markAllNotificationRead(appName); - dispatch(markAllNotificationsAsReadSuccess(data)); + dispatch(markAllNotificationsAsReadSuccess(camelCaseObject(data))); } catch (error) { if (getHttpErrorStatus(error) === 403) { dispatch(markAllNotificationsAsReadDenied()); @@ -102,7 +103,7 @@ export const markNotificationsAsRead = (notificationId) => ( try { dispatch(markNotificationsAsReadRequest({ notificationId })); const data = await markNotificationRead(notificationId); - dispatch(markNotificationsAsReadSuccess(data)); + dispatch(markNotificationsAsReadSuccess(camelCaseObject(data))); } catch (error) { if (getHttpErrorStatus(error) === 403) { dispatch(markNotificationsAsReadDenied()); @@ -118,7 +119,7 @@ export const markNotificationsAsSeen = (appName) => ( try { dispatch(markNotificationsAsSeenRequest({ appName })); const data = await markNotificationSeen(appName); - dispatch(markNotificationsAsSeenSuccess(data)); + dispatch(markNotificationsAsSeenSuccess(camelCaseObject(data))); } catch (error) { if (getHttpErrorStatus(error) === 403) { dispatch(markNotificationsAsSeenDenied()); diff --git a/src/test-utils.js b/src/test-utils.js new file mode 100644 index 0000000..21b8a55 --- /dev/null +++ b/src/test-utils.js @@ -0,0 +1,6 @@ +const executeThunk = async (thunk, dispatch, getState) => { + await thunk(dispatch, getState); + await new Promise(setImmediate); +}; + +export default executeThunk;