diff --git a/src/discussions/in-context-topics/data/__factories__/inContextTopics.factory.js b/src/discussions/in-context-topics/data/__factories__/inContextTopics.factory.js new file mode 100644 index 00000000..d36ea937 --- /dev/null +++ b/src/discussions/in-context-topics/data/__factories__/inContextTopics.factory.js @@ -0,0 +1,78 @@ +import { Factory } from 'rosie'; + +import { getApiBaseUrl } from '../../../../data/constants'; + +Factory.define('topic') + .sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-${idx}`) + .sequence('enabled-in-context', ['enabledInContext'], (idx, enabledInContext) => enabledInContext) + .sequence('name', ['topicNamePrefix'], (idx, topicNamePrefix) => `${topicNamePrefix}-${idx}`) + .sequence('usage-key', ['usageKey'], (idx, usageKey) => usageKey) + .sequence('courseware', ['courseware'], (idx, courseware) => courseware) + + .attr('thread_counts', ['discussionCount', 'questionCount'], (discCount, questCount) => { + Factory.reset('thread-counts'); + return Factory.build('thread-counts', null, { discussionCount: discCount, questionCount: questCount }); + }); + +Factory.define('sub-section') + .sequence('block_id', (idx) => `${idx}`) + .option('topicPrefix', null, '') + .sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-topic-${idx}`) + .sequence('display-name', ['sectionPrefix'], (idx, sectionPrefix) => `Introduction ${sectionPrefix + idx}`) + .option('courseId', null, 'course-v1:edX+DemoX+Demo_Course') + .sequence('legacy_web_url', ['id', 'courseId'], + (idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/block-v1:${id}?experience=legacy`) + .sequence('lms_web_url', ['id', 'courseId'], + (idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/block-v1:${id}`) + .sequence('student_view_url', ['id', 'courseId'], + (idx, id) => `${getApiBaseUrl}/xblock/block-v1:${id}`) + .attr('type', null, 'sequential') + .attr('children', ['id', 'display-name', 'courseId'], (id, name, courseId) => { + Factory.reset('topic'); + return Factory.buildList('topic', 2, null, { + topicPrefix: `${id}`, + enabledInContext: true, + topicNamePrefix: `${name}`, + usageKey: `${courseId.replace('course-v1:', 'block-v1:')} +type@vertical+block@vertical_`, + discussionCount: 1, + questionCount: 1, + }); + }); + +Factory.define('section') + .sequence('block_id', (idx) => `${idx}`) + .option('topicPrefix', null, '') + .sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-topic-${idx}`) + .attr('courseware', null, true) + .sequence('display-name', (idx) => `Introduction ${idx}`) + .option('courseId', null, 'course-v1:edX+DemoX+Demo_Course') + .sequence('legacy_web_url', ['id', 'courseId'], + (idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}?experience=legacy`) + .sequence('lms_web_url', ['id', 'courseId'], + (idx, id, courseId) => `${getApiBaseUrl}/courses/${courseId}/jump_to/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`) + .sequence('student_view_url', ['id', 'courseId'], + (idx, id, courseId) => `${getApiBaseUrl}/xblock/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`) + .attr('type', null, 'chapter') + .attr('children', ['display-name'], (name) => { + Factory.reset('sub-section'); + return Factory.buildList('sub-section', 2, null, { sectionPrefix: `${name}-`, topicPrefix: 'section' }); + }); + +Factory.define('thread-counts') + .sequence('discussion', ['discussionCount'], (idx, discussionCount) => discussionCount) + .sequence('question', ['questionCount'], (idx, questionCount) => questionCount); + +Factory.define('archived-topics') + .attr('id', null, 'archived') + .option('courseId', null, 'course-v1:edX+DemoX+Demo_Course') + .attr('children', ['id', 'courseId'], (id, courseId) => { + Factory.reset('topic'); + return Factory.buildList('topic', 2, null, { + topicPrefix: `${id}`, + enabledInContext: false, + topicNamePrefix: `${id}`, + usageKey: `${courseId.replace('course-v1:', 'block-v1:')} +type@vertical+block@`, + discussionCount: 1, + questionCount: 1, + }); + }); diff --git a/src/discussions/in-context-topics/data/__factories__/index.js b/src/discussions/in-context-topics/data/__factories__/index.js new file mode 100644 index 00000000..64192be4 --- /dev/null +++ b/src/discussions/in-context-topics/data/__factories__/index.js @@ -0,0 +1 @@ +import './inContextTopics.factory'; diff --git a/src/discussions/in-context-topics/data/api.js b/src/discussions/in-context-topics/data/api.js index 3439bd29..4745c8ae 100644 --- a/src/discussions/in-context-topics/data/api.js +++ b/src/discussions/in-context-topics/data/api.js @@ -4,6 +4,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getApiBaseUrl } from '../../../data/constants'; +export const getCourseTopicsApiUrl = () => `${getApiBaseUrl()}/api/discussion/v3/course_topics/`; + export async function getCourseTopicsV3(courseId) { const url = `${getApiBaseUrl()}/api/discussion/v3/course_topics/${courseId}`; const { data } = await getAuthenticatedHttpClient().get(url); diff --git a/src/discussions/in-context-topics/data/api.test.js b/src/discussions/in-context-topics/data/api.test.js new file mode 100644 index 00000000..69adad4d --- /dev/null +++ b/src/discussions/in-context-topics/data/api.test.js @@ -0,0 +1,72 @@ +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 { getCourseTopicsApiUrl, getCourseTopicsV3 } from './api'; +import { fetchCourseTopicsV3 } from './thunks'; + +import './__factories__'; + +const courseId = 'course-v1:edX+TestX+Test_Course'; +const courseId2 = 'course-v1:edX+TestX+Test_Course2'; +const courseTopicsApiUrl = getCourseTopicsApiUrl(); + +let axiosMock = null; +let store; + +describe('In context topic api tests', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + store = initializeStore(); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + test('successfully get topics', async () => { + axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`) + .reply(200, (Factory.buildList('topic', 1, null, { + topicPrefix: 'noncourseware-topic', + enabledInContext: true, + topicNamePrefix: 'general-topic', + usageKey: '', + courseware: false, + discussionCount: 1, + questionCount: 1, + }).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' }))) + .concat(Factory.buildList('archived-topics', 2, null))); + + const response = await getCourseTopicsV3(courseId); + + expect(response).not.toBeUndefined(); + }); + + it('failed to fetch topics', async () => { + axiosMock.onGet(`${courseTopicsApiUrl}${courseId2}`) + .reply(404); + await executeThunk(fetchCourseTopicsV3(courseId2), store.dispatch, store.getState); + + expect(store.getState().inContextTopics.status).toEqual('failed'); + }); + + it('denied to fetch topics', async () => { + axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`) + .reply(403, {}); + await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch); + + expect(store.getState().inContextTopics.status).toEqual('denied'); + }); +}); diff --git a/src/discussions/in-context-topics/data/redux.test.js b/src/discussions/in-context-topics/data/redux.test.js new file mode 100644 index 00000000..4a1826f3 --- /dev/null +++ b/src/discussions/in-context-topics/data/redux.test.js @@ -0,0 +1,186 @@ +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 { getCourseTopicsApiUrl } from './api'; +import { fetchCourseTopicsV3 } from './thunks'; + +import './__factories__'; + +const courseId = 'course-v1:edX+DemoX+Demo_Course'; +const courseTopicsApiUrl = getCourseTopicsApiUrl(); + +let axiosMock; +let store; + +describe('Redux in context topics tests', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + Factory.resetAll(); + store = initializeStore(); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + async function setupMockData() { + axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`) + .reply(200, (Factory.buildList('topic', 1, null, { + topicPrefix: 'noncourseware-topic', + enabledInContext: true, + topicNamePrefix: 'general-topic', + usageKey: '', + courseware: false, + discussionCount: 1, + questionCount: 1, + }).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' }))) + .concat(Factory.buildList('archived-topics', 2, null))); + + await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState); + const state = store.getState(); + return state; + } + + test('successfully load initial states in redux', async () => { + executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState); + const state = store.getState(); + + expect(state.inContextTopics.status).toEqual('in-progress'); + expect(state.inContextTopics.topics).toHaveLength(0); + expect(state.inContextTopics.coursewareTopics).toHaveLength(0); + expect(state.inContextTopics.nonCoursewareTopics).toHaveLength(0); + expect(state.inContextTopics.nonCoursewareIds).toHaveLength(0); + expect(state.inContextTopics.units).toHaveLength(0); + expect(state.inContextTopics.archivedTopics).toHaveLength(0); + expect(state.inContextTopics.filter).toEqual(''); + }); + + test('successfully store all api data of courseware and noncourseware in redux', async () => { + setupMockData().then((state) => { + const { coursewareTopics, nonCoursewareTopics } = state.inContextTopics; + + expect(coursewareTopics).toHaveLength(2); + expect(nonCoursewareTopics).toHaveLength(1); + }); + }); + + test('successfully store the combined list of courseware and noncourseware topics in topics', async () => { + setupMockData().then((state) => { + const { + coursewareTopics, nonCoursewareTopics, archivedTopics, topics, + } = state.inContextTopics; + + expect(topics).toHaveLength(coursewareTopics.length + nonCoursewareTopics.length + archivedTopics.length); + }); + }); + + test('successfully get the posts ', async () => { + setupMockData().then((state) => { + expect(state?.inContextTopics?.status).toEqual('successful'); + }); + }); + + test('successfully checked that the coursewaretopic has three levels', async () => { + setupMockData().then((state) => { + const { coursewareTopics } = state.inContextTopics; + + // contain chapter at first level + coursewareTopics.forEach((chapter, index) => { + expect(chapter.courseware).toEqual(true); + expect(chapter.id).toEqual(`courseware-topic-${index + 1}`); + expect(chapter.type).toEqual('chapter'); + expect(chapter).toHaveProperty('blockId'); + expect(chapter).toHaveProperty('lmsWebUrl'); + expect(chapter).toHaveProperty('legacyWebUrl'); + expect(chapter).toHaveProperty('studentViewUrl'); + + // contain section at second level + chapter.children.forEach((section, secIndex) => { + expect(section.id).toEqual(`section-topic-${secIndex + 1}`); + expect(section.type).toEqual('sequential'); + expect(section).toHaveProperty('blockId'); + expect(section).toHaveProperty('lmsWebUrl'); + expect(section).toHaveProperty('legacyWebUrl'); + expect(section).toHaveProperty('studentViewUrl'); + + // contain sub section at third level + section.children.forEach((subSection, subSecIndex) => { + expect(subSection.enabledInContext).toEqual(true); + expect(subSection.id).toEqual(`${section.id}-${subSecIndex + 1}`); + expect(subSection).toHaveProperty('usageKey'); + expect(subSection).not.toHaveProperty('blockId'); + expect(subSection?.threadCounts?.discussion).toEqual(1); + expect(subSection?.threadCounts?.question).toEqual(1); + }); + }); + }); + }); + }); + + test('successfully checked that the noncoursewaretopic have proper attributes', async () => { + setupMockData().then((state) => { + const { nonCoursewareTopics } = state.inContextTopics; + + nonCoursewareTopics.forEach((topic, index) => { + expect(topic.usageKey).toEqual(''); + expect(topic.id).toEqual(`noncourseware-topic-${index + 1}`); + expect(topic.name).toEqual(`general-topic-${index + 1}`); + expect(topic.enabledInContext).toEqual(true); + expect(topic?.threadCounts?.discussion).toEqual(1); + expect(topic?.threadCounts?.question).toEqual(1); + expect(topic).not.toHaveProperty('blockId'); + }); + }); + }); + + test('nonCoursewareIds successfully contains ids of noncourseware topics', async () => { + setupMockData().then((state) => { + const { nonCoursewareIds, nonCoursewareTopics } = state.inContextTopics; + + nonCoursewareIds.forEach((nonCoursewareId, index) => { + expect(nonCoursewareTopics[index].id).toEqual(nonCoursewareId); + }); + }); + }); + + test('selectUnits successfully contains all sub sections', async () => { + setupMockData().then((state) => { + const subSections = state.inContextTopics.coursewareTopics?.map(x => x.children) + ?.flat()?.map(x => x.children)?.flat(); + const { units } = state.inContextTopics; + + units.forEach(unit => { + const subSection = subSections.find(x => x.id === unit.id); + expect(subSection?.id).toEqual(unit.id); + }); + }); + }); + + test('successfully stored archived data in redux', async () => { + setupMockData().then((state) => { + const { archivedTopics } = state.inContextTopics; + + archivedTopics.forEach((archivedTopic, index) => { + expect(archivedTopic?.enabledInContext).toEqual(false); + expect(archivedTopic?.id).toEqual(`archived-${index + 1}`); + expect(archivedTopic?.usageKey).not.toBeNull(); + expect(archivedTopic?.threadCounts?.discussion).toEqual(1); + expect(archivedTopic?.threadCounts?.question).toEqual(1); + }); + }); + }); +}); diff --git a/src/discussions/in-context-topics/data/selector.test.jsx b/src/discussions/in-context-topics/data/selector.test.jsx new file mode 100644 index 00000000..d27fb940 --- /dev/null +++ b/src/discussions/in-context-topics/data/selector.test.jsx @@ -0,0 +1,147 @@ +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 { getCourseTopicsApiUrl } from './api'; +import { + selectArchivedTopics, + selectCoursewareTopics, + selectLoadingStatus, + selectNonCoursewareIds, + selectNonCoursewareTopics, + selectTopics, + selectUnits, +} from './selectors'; +import { fetchCourseTopicsV3 } from './thunks'; + +import './__factories__'; + +const courseId = 'course-v1:edX+DemoX+Demo_Course'; +const courseTopicsApiUrl = getCourseTopicsApiUrl(); + +let axiosMock; +let store; + +describe('In Context Topics Selector test cases', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + Factory.resetAll(); + store = initializeStore(); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + async function setupMockData() { + axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`) + .reply(200, (Factory.buildList('topic', 1, null, { + topicPrefix: 'noncourseware-topic', + enabledInContext: true, + topicNamePrefix: 'general-topic', + usageKey: '', + courseware: false, + discussionCount: 1, + questionCount: 1, + }).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' }))) + .concat(Factory.buildList('archived-topics', 2, null))); + await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState); + + const state = store.getState(); + return state; + } + + test('should return topics list', async () => { + setupMockData().then((state) => { + const topics = selectTopics(state); + + expect(topics).not.toBeUndefined(); + topics.forEach(data => { + const topicFunc = jest.fn((topic) => { + if (topic.id.includes('noncourseware-topic')) { return true; } + if (topic.id.includes('courseware-topic')) { return true; } + if (topic.id.includes('archived')) { return true; } + return false; + }); + topicFunc(data); + expect(topicFunc).toHaveReturnedWith(true); + }); + }); + }); + + test('should return courseware topics list', async () => { + setupMockData().then((state) => { + const coursewareTopics = selectCoursewareTopics(state); + + expect(coursewareTopics).not.toBeUndefined(); + coursewareTopics.forEach((topic, index) => { + expect(topic?.id).toEqual(`courseware-topic-${index + 1}`); + }); + }); + }); + + test('should return noncourseware topics list', async () => { + setupMockData().then((state) => { + const nonCoursewareTopics = selectNonCoursewareTopics(state); + + expect(nonCoursewareTopics).not.toBeUndefined(); + nonCoursewareTopics.forEach((topic, index) => { + expect(topic?.id).toEqual(`noncourseware-topic-${index + 1}`); + }); + }); + }); + + test('should return noncourseware ids list', async () => { + setupMockData().then((state) => { + const nonCoursewareIds = selectNonCoursewareIds(state); + + expect(nonCoursewareIds).not.toBeUndefined(); + nonCoursewareIds.forEach((id, index) => { + expect(id).toEqual(`noncourseware-topic-${index + 1}`); + }); + }); + }); + + test('should return units list', async () => { + setupMockData().then((state) => { + const units = selectUnits(state); + + expect(units).not.toBeUndefined(); + units.forEach(unit => { + expect(unit?.usageKey).not.toBeNull(); + }); + }); + }); + + test('should return archived topics list', async () => { + setupMockData().then((state) => { + const archivedTopics = selectArchivedTopics(state); + + expect(archivedTopics).not.toBeUndefined(); + archivedTopics.forEach((topic, index) => { + expect(topic.id).toEqual(`archived-${index + 1}`); + }); + }); + }); + + test('should return loading status successful', async () => { + setupMockData().then((state) => { + const status = selectLoadingStatus(state); + + expect(status).toEqual('successful'); + }); + }); +}); diff --git a/src/discussions/in-context-topics/data/slices.js b/src/discussions/in-context-topics/data/slices.js index 95c76fa2..da670578 100644 --- a/src/discussions/in-context-topics/data/slices.js +++ b/src/discussions/in-context-topics/data/slices.js @@ -44,6 +44,7 @@ export const { fetchCourseTopicsRequest, fetchCourseTopicsSuccess, fetchCourseTopicsFailed, + fetchCourseTopicsDenied, setFilter, setSortBy, } = topicsSlice.actions; diff --git a/src/discussions/in-context-topics/data/thunks.js b/src/discussions/in-context-topics/data/thunks.js index 34c1ec42..d4696f21 100644 --- a/src/discussions/in-context-topics/data/thunks.js +++ b/src/discussions/in-context-topics/data/thunks.js @@ -3,8 +3,11 @@ import { reduce } from 'lodash'; import { logError } from '@edx/frontend-platform/logging'; +import { getHttpErrorStatus } from '../../utils'; import { getCourseTopicsV3 } from './api'; -import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices'; +import { + fetchCourseTopicsDenied, fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess, +} from './slices'; function normalizeTopicsV3(topics) { const coursewareUnits = reduce(topics, (arrayOfUnits, chapter) => { @@ -57,7 +60,11 @@ export function fetchCourseTopicsV3(courseId) { const data = await getCourseTopicsV3(courseId); dispatch(fetchCourseTopicsSuccess(normalizeTopicsV3(data))); } catch (error) { - dispatch(fetchCourseTopicsFailed()); + if (getHttpErrorStatus(error) === 403) { + dispatch(fetchCourseTopicsDenied()); + } else { + dispatch(fetchCourseTopicsFailed()); + } logError(error); } };