diff --git a/src/views/studio/lib/studio-activity-actions.js b/src/views/studio/lib/studio-activity-actions.js index 7800bd187..cc7290750 100644 --- a/src/views/studio/lib/studio-activity-actions.js +++ b/src/views/studio/lib/studio-activity-actions.js @@ -42,4 +42,4 @@ const loadActivity = () => ((dispatch, getState) => { }); }); -export {loadActivity}; +export {Errors, loadActivity}; diff --git a/src/views/studio/lib/studio-project-actions.js b/src/views/studio/lib/studio-project-actions.js index 0cf048cd6..ca9fb5d9d 100644 --- a/src/views/studio/lib/studio-project-actions.js +++ b/src/views/studio/lib/studio-project-actions.js @@ -19,7 +19,7 @@ const Errors = keyMirror({ const normalizeError = (err, body, res) => { if (err) return Errors.NETWORK; - if (res.statusCode === 403 && body.mute_status) return Errors.USER_MUTED; + if (res.statusCode === 403 && body && body.mute_status) return Errors.USER_MUTED; if (res.statusCode === 401 || res.statusCode === 403) return Errors.PERMISSION; if (res.statusCode === 404) return Errors.UNKNOWN_PROJECT; if (res.statusCode === 409) return Errors.DUPLICATE; diff --git a/test/unit/redux/project-comment-actions.test.js b/test/unit/redux/project-comment-actions.test.js new file mode 100644 index 000000000..d0ddc0289 --- /dev/null +++ b/test/unit/redux/project-comment-actions.test.js @@ -0,0 +1,125 @@ +import actions from '../../../src/redux/project-comment-actions'; +import configureStore from '../../../src/lib/configure-store'; +import {commentsReducer} from '../../../src/redux/comments'; + +jest.mock('../../../src/lib/api'); +import api from '../../../src/lib/api'; + +let store; + +beforeEach(() => { + api.mockClear(); + // TODO Ideally this would be the entire project page reducer list + store = configureStore({comments: commentsReducer}, {}); +}); + +describe('getTopLevelComments', () => { + test('replies are only loaded for comments with a reply_count > 0', async () => { + api.mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/users/u/projects/123123/comments'); + const body = [ + {id: 1, reply_count: 0}, + {id: 50, reply_count: 1}, + {id: 60, reply_count: 0}, + {id: 70, reply_count: 1} + ]; + callback(null, body, {statusCode: 200}); + }) + .mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/users/u/projects/123123/comments/50/replies'); + const body = [{id: 4, parent_id: 50}]; + callback(null, body, {statusCode: 200}); + }) + .mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/users/u/projects/123123/comments/70/replies'); + const body = [{id: 5, parent_id: 70}]; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(actions.getTopLevelComments(123123, 0, 'u')); + const state = store.getState(); + expect(state.comments.comments.length).toBe(4); + expect(state.comments.replies[50].length).toBe(1); + expect(state.comments.replies[70].length).toBe(1); + expect(state.comments.replies[1]).toBeUndefined(); + expect(state.comments.replies[60]).toBeUndefined(); + }); + test('admin route is used correctly', async () => { + api.mockImplementationOnce((opts) => { + // NB: this route doesn't include the owner username + expect(opts.uri).toBe('/admin/projects/123123/comments'); + expect(opts.authentication).toBe('a-token'); + }); + store.dispatch(actions.getTopLevelComments(123123, 0, 'u', true, 'a-token')); + }); +}); + +describe('getCommentById', () => { + test('getting a top level comment will not load replies if there arent any', async () => { + api.mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/users/u/projects/123123/comments/111'); + const body = {id: 111, parent_id: null, reply_count: 0}; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(actions.getCommentById(123123, 111, 'u')); + const state = store.getState(); + expect(state.comments.comments.length).toBe(1); + expect(state.comments.replies[111]).toBeUndefined(); + }); + + test('admin route is used correctly', async () => { + api.mockImplementationOnce((opts) => { + // NB: this route doesn't include the owner username + expect(opts.uri).toBe('/admin/projects/123123/comments/111'); + expect(opts.authentication).toBe('a-token'); + }); + store.dispatch(actions.getCommentById(123123, 111, 'u', true, 'a-token')); + }); + + test('getting a top level comment will load replies', async () => { + api.mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/users/u/projects/123123/comments/111'); + const body = {id: 111, parent_id: null, reply_count: 2}; + callback(null, body, {statusCode: 200}); + }).mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/users/u/projects/123123/comments/111/replies'); + const body = [{id: 1, parent_id: 111}]; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(actions.getCommentById(123123, 111, 'u')); + const state = store.getState(); + expect(state.comments.comments.length).toBe(1); + expect(state.comments.replies[111].length).toBe(1); + }); + + test('getting a reply comment will load the parent comment and its other replies', async () => { + // Expect 3 requests. First 111, which is a reply comment, maybe linked to from messages + // Second is for 111's parent, which is 555. + // Third is for 555's replies, which returns 111 and 112 + api.mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/users/u/projects/123123/comments/111'); + const body = {id: 111, parent_id: 555}; + callback(null, body, {statusCode: 200}); + }) + .mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/users/u/projects/123123/comments/555'); + const body = {id: 555, reply_count: 2}; + callback(null, body, {statusCode: 200}); + }) + .mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/users/u/projects/123123/comments/555/replies'); + const body = [{id: 111, parent_id: 555}, {id: 112, parent_id: 555}]; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(actions.getCommentById(123123, 111, 'u')); + const state = store.getState(); + expect(state.comments.comments.length).toBe(1); + expect(state.comments.replies[555].length).toBe(2); + }); +}); + +describe.skip('addNewComment', () => { }); +describe.skip('deleteComment', () => { }); +describe.skip('reportComment', () => { }); +describe.skip('resetComments', () => { }); +describe.skip('reportComment', () => { }); +describe.skip('getReplies', () => { }); diff --git a/test/unit/redux/studio-activity-actions.test.js b/test/unit/redux/studio-activity-actions.test.js new file mode 100644 index 000000000..a3587a1e9 --- /dev/null +++ b/test/unit/redux/studio-activity-actions.test.js @@ -0,0 +1,54 @@ +import { + Errors, + loadActivity +} from '../../../src/views/studio/lib/studio-activity-actions'; +import {activity} from '../../../src/views/studio/lib/redux-modules'; +import {reducers, initialState} from '../../../src/views/studio/studio-redux'; +import configureStore from '../../../src/lib/configure-store'; + +jest.mock('../../../src/lib/api'); +import api from '../../../src/lib/api'; + +let store; + +beforeEach(() => { + api.mockClear(); + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123} + }); +}); + +describe('loadActivity', () => { + test('it populates the activity list', () => { + api.mockImplementation((opts, callback) => { + const body = [{id: 1}, {id: 2}, {id: 3, datetime_created: 'abc'}]; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(loadActivity()); + let items = activity.selector(store.getState()).items; + expect(api.mock.calls[0][0].uri).toBe('/studios/123123/activity/'); + expect(api.mock.calls[0][0].params.offset).toBeUndefined(); + expect(items.length).toBe(3); + expect(items[0].id).toBe(1); + + // On next loadActivity request, it should include the last activity items + // datetime_created as the dateLimit. It should de-duplicate based on id + api.mockImplementation((opts, callback) => { + const body = [{id: 3}, {id: 4}, {id: 5, datetime_created: 'def'}]; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(loadActivity()); + expect(api.mock.calls[1][0].params.dateLimit).toBe('abc'); + items = activity.selector(store.getState()).items; + expect(items.length).toBe(5); // id=3 should get de-duplicated + }); + + test('errors are set on the activity state', () => { + api.mockImplementation((opts, callback) => { + callback(null, null, {statusCode: 500}); + }); + store.dispatch(loadActivity()); + expect(activity.selector(store.getState()).error).toBe(Errors.SERVER); + }); +}); diff --git a/test/unit/redux/studio-comment-actions.test.js b/test/unit/redux/studio-comment-actions.test.js new file mode 100644 index 000000000..1ba8eae5c --- /dev/null +++ b/test/unit/redux/studio-comment-actions.test.js @@ -0,0 +1,139 @@ +import actions from '../../../src/redux/studio-comment-actions'; +import {reducers, initialState} from '../../../src/views/studio/studio-redux'; +import configureStore from '../../../src/lib/configure-store'; + +jest.mock('../../../src/lib/api'); +import api from '../../../src/lib/api'; + +let store; + +beforeEach(() => { + api.mockClear(); +}); + +describe('getTopLevelComments', () => { + test('replies are only loaded for comments with a reply_count > 0', async () => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123} + }); + api.mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/comments'); + const body = [ + {id: 1, reply_count: 0}, + {id: 50, reply_count: 1}, + {id: 60, reply_count: 0}, + {id: 70, reply_count: 1} + ]; + callback(null, body, {statusCode: 200}); + }) + .mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/comments/50/replies'); + const body = [{id: 4, parent_id: 50}]; + callback(null, body, {statusCode: 200}); + }) + .mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/comments/70/replies'); + const body = [{id: 5, parent_id: 70}]; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(actions.getTopLevelComments()); + const state = store.getState(); + expect(state.comments.comments.length).toBe(4); + expect(state.comments.replies[50].length).toBe(1); + expect(state.comments.replies[70].length).toBe(1); + expect(state.comments.replies[1]).toBeUndefined(); + expect(state.comments.replies[60]).toBeUndefined(); + }); + test('admin route is used when the session shows the user is an admin', async () => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123}, + session: { + session: { + user: {token: 'a-token'}, + permissions: {admin: true} + } + } + }); + api.mockImplementationOnce((opts) => { + expect(opts.uri).toBe('/admin/studios/123123/comments'); + expect(opts.authentication).toBe('a-token'); + }); + store.dispatch(actions.getTopLevelComments()); + }); +}); + +describe('getCommentById', () => { + test('getting a top level comment will not load replies if there arent any', async () => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123} + }); + api.mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/comments/111'); + const body = {id: 111, parent_id: null, reply_count: 0}; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(actions.getCommentById(111)); + const state = store.getState(); + expect(state.comments.comments.length).toBe(1); + expect(state.comments.replies[111]).toBeUndefined(); + }); + + test('getting a top level comment will load replies', async () => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123} + }); + api.mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/comments/111'); + const body = {id: 111, parent_id: null, reply_count: 2}; + callback(null, body, {statusCode: 200}); + }).mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/comments/111/replies'); + const body = [{id: 1, parent_id: 111}]; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(actions.getCommentById(111)); + const state = store.getState(); + expect(state.comments.comments.length).toBe(1); + expect(state.comments.replies[111].length).toBe(1); + }); + + test('getting a reply comment will load the parent comment and its other replies', async () => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123} + }); + // Expect 3 requests. First 111, which is a reply comment, maybe linked to from messages + // Second is for 111's parent, which is 555. + // Third is for 555's replies, which returns 111 and 112 + api.mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/comments/111'); + const body = {id: 111, parent_id: 555}; + callback(null, body, {statusCode: 200}); + }) + .mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/comments/555'); + const body = {id: 555, reply_count: 2}; + callback(null, body, {statusCode: 200}); + }) + .mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/comments/555/replies'); + const body = [{id: 111, parent_id: 555}, {id: 112, parent_id: 555}]; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(actions.getCommentById(111)); + const state = store.getState(); + expect(state.comments.comments.length).toBe(1); + expect(state.comments.replies[555].length).toBe(2); + }); +}); + +describe.skip('addNewComment', () => { }); +describe.skip('deleteComment', () => { }); +describe.skip('reportComment', () => { }); +describe.skip('resetComments', () => { }); +describe.skip('reportComment', () => { }); +describe.skip('getReplies', () => { }); diff --git a/test/unit/redux/studio-member-actions.test.js b/test/unit/redux/studio-member-actions.test.js index 86062b93f..7a9bece76 100644 --- a/test/unit/redux/studio-member-actions.test.js +++ b/test/unit/redux/studio-member-actions.test.js @@ -22,6 +22,36 @@ beforeEach(() => { api.mockClear(); }); + +const storage = { + max: undefined, + items: [] +}; + +Object.defineProperty(storage, 'max', {writable: false, value: 5000}); + +let currentStorage = 'undefined'; + +const storageUsed = () => { + if (currentStorage) { + return currentStorage; + } + currentStorage = 0; + for (const i = 0; i < storage.length(); i++) { + currentStorage += storage.items[i].weigth; + } + return currentStorage; +}; + +const add = (item) => { + if (storage.max - item.weight >= storageUsed()) { + storage.items.push(item); + currentStorage += iten.weight; + } +}; + +add({weight: 100}); + describe('loadManagers', () => { test('it populates the managers list', () => { store = configureStore(reducers, { diff --git a/test/unit/redux/studio-project-actions.test.js b/test/unit/redux/studio-project-actions.test.js new file mode 100644 index 000000000..30cb3eca9 --- /dev/null +++ b/test/unit/redux/studio-project-actions.test.js @@ -0,0 +1,191 @@ +import {selectStudioManagerCount} from '../../../src/redux/studio'; +import { + Errors, + loadProjects, + addProject, + removeProject +} from '../../../src/views/studio/lib/studio-project-actions'; +import {projects} from '../../../src/views/studio/lib/redux-modules'; +import {reducers, initialState} from '../../../src/views/studio/studio-redux'; +import configureStore from '../../../src/lib/configure-store'; + +jest.mock('../../../src/lib/api'); +import api from '../../../src/lib/api'; + +let store; + +beforeEach(() => { + api.mockClear(); +}); + +describe('loadProjects', () => { + test('it populates the projects list', () => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123} + }); + api.mockImplementation((opts, callback) => { + const body = [{id: 1}, {id: 2}, {id: 3}]; + callback(null, body, {statusCode: 200}); + }); + store.dispatch(loadProjects()); + let items = projects.selector(store.getState()).items; + expect(api.mock.calls[0][0].uri).toBe('/studios/123123/projects/'); + expect(api.mock.calls[0][0].params.offset).toBe(0); + expect(items.length).toBe(3); + expect(items[0].id).toBe(1); + + // Include the new offset next time it is called + store.dispatch(loadProjects()); + expect(api.mock.calls[1][0].params.offset).toBe(3); + items = projects.selector(store.getState()).items; + expect(items.length).toBe(6); + }); + + test('it correctly uses the admin route when possible', () => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123}, + session: { + session: { + user: {token: 'a-token'}, + permissions: {admin: true} + } + } + }); + api.mockImplementation((opts) => { + expect(opts.uri).toBe('/admin/studios/123123/projects/'); + expect(opts.authentication).toBe('a-token'); + }); + store.dispatch(loadProjects()); + }); + + test('errors are set on the projects state', () => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123} + }); + api.mockImplementation((opts, callback) => { + callback(null, null, {statusCode: 500}); + }); + store.dispatch(loadProjects()); + expect(projects.selector(store.getState()).error).toBe(Errors.SERVER); + }); +}); + +describe('addProject', () => { + beforeEach(() => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123} + }); + }); + test('makes a POST and a GET and then combines the result and puts it in redux', async () => { + const postResponse = { + projectId: '111', + actorId: 'actor-id' + }; + const getResponse = { + title: 'project-title', + image: 'project-image', + author: { + id: 'author-id', + username: 'author-username', + profile: {images: [1, 2, 3]} + } + }; + api.mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/project/111'); + expect(opts.method).toBe('POST'); + callback(null, postResponse, {statusCode: 200}); + }).mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/projects/111'); + callback(null, getResponse, {statusCode: 200}); + }); + await store.dispatch(addProject('scratch.mit.edu/projects/111')); + const {items} = projects.selector(store.getState()); + expect(items.length).toBe(1); + // Item in redux is a combination of get/post that matches the shape of the studio projects endpoint + expect(items[0]).toMatchObject({ + id: 111, + actor_id: 'actor-id', + title: 'project-title', + image: 'project-image', + creator_id: 'author-id', + username: 'author-username', + avatar: [1, 2, 3] + }); + }); + test('submitting an invalid returns error without network requests', async () => { + await expect(store.dispatch(addProject('abc'))) + .rejects.toBe(Errors.UNKNOWN_PROJECT); + expect(api.mock.calls.length).toBe(0); + }); + test('submitting an existing project returns error without network requests', async () => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123}, + projects: {items: [{id: 999}]} + }); + await expect(store.dispatch(addProject('localhost:800/projects/999'))) + .rejects.toBe(Errors.DUPLICATE); + expect(api.mock.calls.length).toBe(0); + }); + test('rate limit server response', async () => { + api.mockImplementationOnce((opts, callback) => { + callback(null, null, {statusCode: 429}); + }); + await expect(store.dispatch(addProject('localhost:800/projects/999'))) + .rejects.toBe(Errors.RATE_LIMIT); + }); + test('unknown project server response', async () => { + + api.mockImplementationOnce((opts, callback) => { + callback(null, null, {statusCode: 404}); + }); + await expect(store.dispatch(addProject('localhost:800/projects/999'))) + .rejects.toBe(Errors.UNKNOWN_PROJECT); + }); + test('not allowed server response', async () => { + api.mockImplementationOnce((opts, callback) => { + callback(null, null, {statusCode: 403}); + }); + await expect(store.dispatch(addProject('localhost:800/projects/999'))) + .rejects.toBe(Errors.PERMISSION); + }); + test('muted server response', async () => { + api.mockImplementationOnce((opts, callback) => { + callback(null, {mute_status: {}}, {statusCode: 403}); + }); + await expect(store.dispatch(addProject('localhost:800/projects/999'))) + .rejects.toBe(Errors.USER_MUTED); + }); +}); + +describe('removeProject', () => { + beforeEach(() => { + store = configureStore(reducers, { + ...initialState, + studio: {id: 123123}, + projects: {items: [{id: 999}]} + }); + }); + test('makes a DELETE and removes the item from redux', async () => { + api.mockImplementationOnce((opts, callback) => { + expect(opts.uri).toBe('/studios/123123/project/999'); + expect(opts.method).toBe('DELETE'); + callback(null, {}, {statusCode: 200}); + }); + await store.dispatch(removeProject(999)); + const {items} = projects.selector(store.getState()); + expect(items.length).toBe(0); + }); + + test('errors are set on the projects state', async () => { + api.mockImplementationOnce((opts, callback) => { + callback(null, {}, {statusCode: 500}); + }); + await expect(store.dispatch(removeProject(999))) + .rejects.toBe(Errors.SERVER); + }); +});