From fae3e5e60dd6423fc6de2ef831fc3ed722da4349 Mon Sep 17 00:00:00 2001 From: srallen Date: Mon, 14 Jun 2021 13:15:42 -0500 Subject: [PATCH] Integrate UPP store in project app (#2238) * Create new parent store for stats and UPP. Make it child of User. * Tests for UPP * Only request for UPP if not already present. Rewrite tests to be async. * Fix spec after rebase --- .../ClassifierWrapperConnector.js | 5 +- .../ClassifierWrapperConnector.spec.js | 3 +- .../YourStats/YourStatsContainer.js | 2 +- .../DailyClassificationsChartContainer.js | 2 +- packages/app-project/stores/Store.js | 4 +- packages/app-project/stores/User.js | 5 +- .../app-project/stores/UserPersonalization.js | 61 +++++ .../stores/UserPersonalization.spec.js | 250 ++++++++++++++++++ .../stores/UserProjectPreferences.js | 64 ++++- .../stores/UserProjectPreferences.spec.js | 232 ++++++++++++++++ packages/app-project/stores/YourStats.js | 73 +---- packages/app-project/stores/YourStats.spec.js | 204 ++------------ 12 files changed, 644 insertions(+), 261 deletions(-) create mode 100644 packages/app-project/stores/UserPersonalization.js create mode 100644 packages/app-project/stores/UserPersonalization.spec.js diff --git a/packages/app-project/src/screens/ClassifyPage/components/ClassifierWrapper/ClassifierWrapperConnector.js b/packages/app-project/src/screens/ClassifyPage/components/ClassifierWrapper/ClassifierWrapperConnector.js index bd016a3fe9..646184d217 100644 --- a/packages/app-project/src/screens/ClassifyPage/components/ClassifierWrapper/ClassifierWrapperConnector.js +++ b/packages/app-project/src/screens/ClassifyPage/components/ClassifierWrapper/ClassifierWrapperConnector.js @@ -14,8 +14,7 @@ function useStore() { user, ui: { mode - }, - yourStats + } } = store return ({ @@ -24,7 +23,7 @@ function useStore() { project, recents, user, - yourStats + yourStats: user.personalization }) } diff --git a/packages/app-project/src/screens/ClassifyPage/components/ClassifierWrapper/ClassifierWrapperConnector.spec.js b/packages/app-project/src/screens/ClassifyPage/components/ClassifierWrapper/ClassifierWrapperConnector.spec.js index d6bdb8a4c4..4b0684ecbb 100644 --- a/packages/app-project/src/screens/ClassifyPage/components/ClassifierWrapper/ClassifierWrapperConnector.spec.js +++ b/packages/app-project/src/screens/ClassifyPage/components/ClassifierWrapper/ClassifierWrapperConnector.spec.js @@ -5,7 +5,6 @@ import React from 'react' import asyncStates from '@zooniverse/async-states' import initStore from '@stores/initStore' -import ClassifierWrapper from './ClassifierWrapper' import ClassifierWrapperConnector from './ClassifierWrapperConnector' describe('Component > ClassifierWrapperConnector', function () { @@ -59,7 +58,7 @@ describe('Component > ClassifierWrapperConnector', function () { }) it('should include your personal stats', function () { - expect(wrapper.props().yourStats).to.equal(store.yourStats) + expect(wrapper.props().yourStats).to.equal(store.user.personalization) }) it('should include the project', function () { diff --git a/packages/app-project/src/screens/ClassifyPage/components/YourStats/YourStatsContainer.js b/packages/app-project/src/screens/ClassifyPage/components/YourStats/YourStatsContainer.js index e8b2a08e56..dfa783f389 100644 --- a/packages/app-project/src/screens/ClassifyPage/components/YourStats/YourStatsContainer.js +++ b/packages/app-project/src/screens/ClassifyPage/components/YourStats/YourStatsContainer.js @@ -6,7 +6,7 @@ import YourStats from './YourStats' import withRequireUser from '@shared/components/withRequireUser' function storeMapper (stores) { - const { project, yourStats: { counts } } = stores.store + const { project, user: { personalization: { counts } } } = stores.store return { counts, projectName: project['display_name'] diff --git a/packages/app-project/src/screens/ClassifyPage/components/YourStats/components/DailyClassificationsChart/DailyClassificationsChartContainer.js b/packages/app-project/src/screens/ClassifyPage/components/YourStats/components/DailyClassificationsChart/DailyClassificationsChartContainer.js index f486bac5d3..43c06677da 100644 --- a/packages/app-project/src/screens/ClassifyPage/components/YourStats/components/DailyClassificationsChart/DailyClassificationsChartContainer.js +++ b/packages/app-project/src/screens/ClassifyPage/components/YourStats/components/DailyClassificationsChart/DailyClassificationsChartContainer.js @@ -6,7 +6,7 @@ import React, { Component } from 'react' import DailyClassificationsChart from './DailyClassificationsChart' function storeMapper (stores) { - const { project, yourStats: { counts, thisWeek } } = stores.store + const { project, user: { personalization: { counts, stats: { thisWeek } } } } = stores.store return { counts, thisWeek, diff --git a/packages/app-project/stores/Store.js b/packages/app-project/stores/Store.js index 10e540da62..d9b8046720 100644 --- a/packages/app-project/stores/Store.js +++ b/packages/app-project/stores/Store.js @@ -6,7 +6,6 @@ import Project from './Project' import Recents from './Recents' import UI from './UI' import User from './User' -import YourStats from './YourStats' const Store = types .model('Store', { @@ -14,8 +13,7 @@ const Store = types project: types.optional(Project, {}), recents: types.optional(Recents, {}), ui: types.optional(UI, {}), - user: types.optional(User, {}), - yourStats: types.optional(YourStats, {}) + user: types.optional(User, {}) }) .views(self => ({ diff --git a/packages/app-project/stores/User.js b/packages/app-project/stores/User.js index 8a27d09a70..ea2ebc15ed 100644 --- a/packages/app-project/stores/User.js +++ b/packages/app-project/stores/User.js @@ -1,7 +1,7 @@ import asyncStates from '@zooniverse/async-states' import { flow, types } from 'mobx-state-tree' import auth from 'panoptes-client/lib/auth' - +import UserPersonalization from './UserPersonalization' import numberString from './types/numberString' const User = types @@ -11,7 +11,8 @@ const User = types error: types.maybeNull(types.frozen({})), id: types.maybeNull(numberString), login: types.maybeNull(types.string), - loadingState: types.optional(types.enumeration('state', asyncStates.values), asyncStates.loading) + loadingState: types.optional(types.enumeration('state', asyncStates.values), asyncStates.loading), + personalization: types.optional(UserPersonalization, {}) }) .views(self => ({ diff --git a/packages/app-project/stores/UserPersonalization.js b/packages/app-project/stores/UserPersonalization.js new file mode 100644 index 0000000000..1a67d7fa92 --- /dev/null +++ b/packages/app-project/stores/UserPersonalization.js @@ -0,0 +1,61 @@ +import { addDisposer, getRoot, types } from 'mobx-state-tree' +import { autorun } from 'mobx' +import UserProjectPreferences from './UserProjectPreferences' +import YourStats from './YourStats' + +const UserPersonalization = types + .model('UserPersonalization', { + projectPreferences: types.optional(UserProjectPreferences, {}), + stats: types.optional(YourStats, {}), + totalClassificationCount: 0 + }) + .volatile(self => ({ + sessionCount: 0 + })) + .views(self => ({ + get counts() { + const todaysDate = new Date() + let today + try { + const todaysCount = self.stats.thisWeek.length === 7 + ? self.stats.thisWeek[todaysDate.getDay() - 1].count + : 0 + today = todaysCount + self.sessionCount + } catch (error) { + today = 0 + } + + return { + today, + total: self.totalClassificationCount + } + } + })) + .actions(self => { + function createParentObserver() { + const parentDisposer = autorun(() => { + const { project, user } = getRoot(self) + if (project.id && user.id) { + self.projectPreferences.fetchResource() + self.stats.fetchDailyCounts() + } + }) + addDisposer(self, parentDisposer) + } + return { + afterAttach() { + createParentObserver() + }, + + increment() { + self.sessionCount = self.sessionCount + 1 + self.totalClassificationCount = self.totalClassificationCount + 1 + }, + + setTotalClassificationCount(count) { + self.totalClassificationCount = count + } + } + }) + +export default UserPersonalization \ No newline at end of file diff --git a/packages/app-project/stores/UserPersonalization.spec.js b/packages/app-project/stores/UserPersonalization.spec.js new file mode 100644 index 0000000000..642b856249 --- /dev/null +++ b/packages/app-project/stores/UserPersonalization.spec.js @@ -0,0 +1,250 @@ +import sinon from 'sinon' +import auth from 'panoptes-client/lib/auth' +import nock from 'nock' + +import initStore from './initStore' +import UserPersonalization from './UserPersonalization' +import { statsClient } from './YourStats' + +describe('Stores > UserPersonalization', function () { + let rootStore, nockScope + const project = { + id: '2', + display_name: 'Hello', + slug: 'test/project' + } + + before(function () { + sinon.stub(console, 'error') + const MOCK_DAILY_COUNTS = [ + { count: 12, period: '2019-09-29' }, + { count: 12, period: '2019-09-30' }, + { count: 13, period: '2019-10-01' }, + { count: 14, period: '2019-10-02' }, + { count: 10, period: '2019-10-03' }, + { count: 11, period: '2019-10-04' }, + { count: 8, period: '2019-10-05' }, + { count: 15, period: '2019-10-06' } + ] + nockScope = nock('https://panoptes-staging.zooniverse.org/api') + .persist() + .get('/project_preferences') + .query(true) + .reply(200, { + project_preferences: [ + { activity_count: 23 } + ] + }) + .get('/collections') // This is to get the collections store to not make real requests + .query(true) + .reply(200) + .post('/collections') + .query(true) + .reply(200) + rootStore = initStore(true, { project }) + sinon.spy(rootStore.client.panoptes, 'get') + sinon.stub(statsClient, 'request').callsFake(() => Promise.resolve({ statsCount: MOCK_DAILY_COUNTS })) + }) + + after(function () { + console.error.restore() + rootStore.client.panoptes.get.restore() + statsClient.request.restore() + nock.cleanAll() + }) + + it('should exist', function () { + expect(rootStore.user.personalization).to.be.ok() + }) + + describe('with a project and user', function () { + let clock + + before(function () { + clock = sinon.useFakeTimers({ now: new Date(2019, 9, 1, 12), toFake: ['Date'] }) + const user = { + id: '123', + login: 'test.user' + } + rootStore.user.set(user) + }) + + after(function () { + clock.restore() + rootStore.client.panoptes.get.resetHistory() + }) + + it('should trigger the child UPP node store to request user preferences', function () { + const authorization = 'Bearer ' + const endpoint = '/project_preferences' + const query = { + project_id: '2', + user_id: '123' + } + expect(rootStore.client.panoptes.get.withArgs(endpoint, query, { authorization })).to.have.been.calledOnce() + }) + + it('should trigger the child YourStats node to request user statistics', function () { + const query = `{ + statsCount( + eventType: "classification", + interval: "1 Day", + window: "1 Week", + projectId: "2", + userId: "123" + ){ + period, + count + } + }` + expect(statsClient.request).to.have.been.calledOnceWith(query.replace(/\s+/g, ' ')) + }) + + describe('incrementing your classification count', function () { + before(function () { + const user = { + id: '123', + login: 'test.user', + personalization: { + projectPreferences: { + activity_count: 23 + } + } + } + rootStore = initStore(true, { project, user }) + rootStore.user.personalization.setTotalClassificationCount(23) + rootStore.user.personalization.increment() + }) + + it('should add 1 to your total count', function () { + expect(rootStore.user.personalization.totalClassificationCount).to.equal(24) + }) + + it('should add 1 to your session count', function () { + expect(rootStore.user.personalization.sessionCount).to.equal(1) + }) + }) + }) + + describe('with a project and anonymous user', function () { + before(function () { + rootStore = initStore(true, { project }) + }) + + it('should not trigger the child UPP store to request user preferences from Panoptes', function () { + expect(rootStore.client.panoptes.get).to.have.not.been.called() + }) + + it('should start counting from 0', function () { + expect(rootStore.user.personalization.totalClassificationCount).to.equal(0) + }) + + describe('incrementing your classification count', function () { + before(function () { + rootStore.user.personalization.increment() + }) + + it('should add 1 to your total count', function () { + expect(rootStore.user.personalization.totalClassificationCount).to.equal(1) + }) + }) + }) + + describe('when Zooniverse auth is down.', function () { + before(function () { + rootStore = initStore(true, { project }) + const user = { + id: '123', + login: 'test.user' + } + sinon.stub(auth, 'checkBearerToken').callsFake(() => Promise.reject(new Error('Auth is not available'))) + rootStore.user.set(user) + rootStore.user.personalization.increment() + }) + + after(function () { + auth.checkBearerToken.restore() + }) + + it('should count session classifications from 0', function () { + expect(rootStore.user.personalization.totalClassificationCount).to.equal(1) + }) + }) + + describe('on Panoptes API errors', function () { + before(function () { + rootStore = initStore(true, { project }) + const user = { + id: '123', + login: 'test.user' + } + nockScope + .get('/project_preferences') + .query(true) + .replyWithError('Panoptes is not available') + rootStore.user.set(user) + rootStore.user.personalization.increment() + }) + + it('should count session classifications from 0', function () { + expect(rootStore.user.personalization.totalClassificationCount).to.equal(1) + }) + }) + + describe('counts view', function () { + it('should return the expected counts with no data', function () { + const personalizationStore = UserPersonalization.create() + expect(personalizationStore.counts).to.deep.equal({ + today: 0, + total: 0 + }) + }) + + describe('total count', function () { + it('should get the total count from the store `totalClassificationCount` value', function () { + const personalizationStore = UserPersonalization.create() + personalizationStore.increment() + personalizationStore.increment() + personalizationStore.increment() + personalizationStore.increment() + expect(personalizationStore.counts.total).to.equal(4) + }) + }) + + describe('today\'s count', function () { + let clock + + before(function () { + clock = sinon.useFakeTimers({ now: new Date(2019, 9, 1, 12), toFake: ['Date'] }) + }) + + after(function () { + clock.restore() + }) + + it('should get today\'s count from the store\'s counts for this week', function () { + const MOCK_DAILY_COUNTS = [ + { count: 12, period: '2019-09-30T00:00:00Z' }, + { count: 13, period: '2019-10-01T00:00:00Z' }, + { count: 14, period: '2019-10-02T00:00:00Z' }, + { count: 10, period: '2019-10-03T00:00:00Z' }, + { count: 11, period: '2019-10-04T00:00:00Z' }, + { count: 8, period: '2019-10-05T00:00:00Z' }, + { count: 15, period: '2019-10-06T00:00:00Z' } + ] + const personalizationStore = UserPersonalization.create({ stats: { thisWeek: MOCK_DAILY_COUNTS } }) + expect(personalizationStore.counts.today).to.equal(MOCK_DAILY_COUNTS[1].count) + }) + + it('should be `0` if there are no classifications today', function () { + const MOCK_DAILY_COUNTS = [ + { count: 12, period: '2019-01-03T00:00:00Z' }, + { count: 13, period: '2019-01-02T00:00:00Z' }, + { count: 14, period: '2019-01-01T00:00:00Z' } + ] + const personalizationStore = UserPersonalization.create({ stats: { thisWeek: MOCK_DAILY_COUNTS } }) + expect(personalizationStore.counts.today).to.equal(0) + }) + }) + }) +}) diff --git a/packages/app-project/stores/UserProjectPreferences.js b/packages/app-project/stores/UserProjectPreferences.js index 545e3767a9..f756c32c69 100644 --- a/packages/app-project/stores/UserProjectPreferences.js +++ b/packages/app-project/stores/UserProjectPreferences.js @@ -1,4 +1,7 @@ -import { types } from 'mobx-state-tree' +import { applySnapshot, flow, getRoot, types } from 'mobx-state-tree' +import auth from 'panoptes-client/lib/auth' +import asyncStates from '@zooniverse/async-states' +import numberString from './types/numberString' const Preferences = types .model('Preferences', { @@ -11,14 +14,73 @@ const UserProjectPreferences = types .model('UserProjectPreferences', { activity_count: types.maybe(types.number), activity_count_by_workflow: types.maybe(types.frozen()), + error: types.maybeNull(types.frozen({})), + id: types.maybe(numberString), links: types.maybe( types.frozen({ project: types.string, user: types.string }) ), + loadingState: types.optional(types.enumeration('state', asyncStates.values), asyncStates.initialized), preferences: types.maybe(Preferences), settings: types.maybe(types.frozen()) }) + .actions(self => { + return { + reset() { + const resetSnapshot = { + activity_count: undefined, + activity_count_by_workflow: undefined, + error: undefined, + id: undefined, + links: undefined, + loadingState: asyncStates.initialized, + preferences: undefined, + settings: undefined + } + applySnapshot(self, resetSnapshot) + }, + + setResource(resource) { + applySnapshot(self, resource) + }, + + handleError(error) { + console.error(error) + self.error = error + self.setLoadingState(asyncStates.error) + }, + + setLoadingState(state) { + self.loadingState = state + }, + + fetchResource: flow(function* fetchResource() { + if (!self.id) { + const { client, project, user } = getRoot(self) + self.setLoadingState(asyncStates.loading) + try { + const token = yield auth.checkBearerToken() + const authorization = `Bearer ${token}` + const query = { + project_id: project.id, + user_id: user.id + } + + const response = yield client.panoptes.get('/project_preferences', query, { authorization }) + const [preferences] = response.body.project_preferences + if (preferences) { + self.setResource(preferences) + user.personalization.setTotalClassificationCount(preferences.activity_count) + } + self.setLoadingState(asyncStates.success) + } catch (error) { + self.handleError(error) + } + } + }) + } + }) export default UserProjectPreferences \ No newline at end of file diff --git a/packages/app-project/stores/UserProjectPreferences.spec.js b/packages/app-project/stores/UserProjectPreferences.spec.js index 7064f4aa10..9b8d6ef482 100644 --- a/packages/app-project/stores/UserProjectPreferences.spec.js +++ b/packages/app-project/stores/UserProjectPreferences.spec.js @@ -1,7 +1,239 @@ +import sinon from 'sinon' +import nock from 'nock' +import initStore from './initStore' +import asyncStates from '@zooniverse/async-states' +import { statsClient } from './YourStats' +import { expect } from 'chai' import UserProjectPreferences from './UserProjectPreferences' describe('Stores > UserProjectPreferences', function () { + let rootStore + const project = { + id: '2', + display_name: 'Hello', + slug: 'test/project' + } + const user = { + id: '123', + login: 'test-user', + personalization: { + projectPreferences: { + id: '5' + } + } + } + const initialState = { + activity_count: undefined, + activity_count_by_workflow: undefined, + error: undefined, + id: undefined, + links: undefined, + loadingState: asyncStates.initialized, + preferences: undefined, + settings: undefined + } + const upp = { + activity_count: 23, + activity_count_by_workflow: {}, + id: '555', + links: { + project: '2', + user: '123' + }, + preferences: { + minicourses: undefined, + selected_workflow: undefined, + tutorials_completed_at: undefined + }, + settings: {} + } + const authorization = 'Bearer ' + const endpoint = '/project_preferences' + const query = { + project_id: '2', + user_id: '123' + } + + before(function () { + sinon.stub(console, 'error') + sinon.stub(statsClient, 'request') + rootStore = initStore(true, { + project, + user + }) + sinon.spy(rootStore.client.panoptes, 'get') + }) + + beforeEach(function () { + rootStore.client.panoptes.get.resetHistory() + }) + + after(function () { + statsClient.request.restore() + rootStore.client.panoptes.get.restore() + console.error.restore() + rootStore = null + }) + it('should exist', function () { expect(UserProjectPreferences).to.be.an('object') }) + + it('should not request for the resource if a snapshot has been applied', function () { + expect(rootStore.client.panoptes.get.withArgs(endpoint, query, { authorization })).to.not.have.been.called() + }) + + describe('Action > fetchResource', function () { + describe('when there is a resource in the response', function () { + let nockScope + before(function () { + nockScope = nock('https://panoptes-staging.zooniverse.org/api') + .persist() + .get('/project_preferences') + .query(true) + .reply(200, { + project_preferences: [ + upp + ] + }) + .get('/collections') // This is to prevent the collections store from making real requests + .query(true) + .reply(200) + .post('/collections') + .query(true) + .reply(200) + + // resetting for a clean test from the prior upper scope + rootStore.user.personalization.projectPreferences.reset() + }) + + after(function () { + rootStore.user.personalization.projectPreferences.reset() + rootStore.user.personalization.setTotalClassificationCount(0) + nock.cleanAll() + nockScope = null + }) + + it('should request the user project preferences resource', async function () { + expect(rootStore.client.panoptes.get).to.not.have.been.called() + expect(rootStore.user.personalization.projectPreferences.loadingState).to.equal(asyncStates.initialized) + await rootStore.user.personalization.projectPreferences.fetchResource() + expect(rootStore.client.panoptes.get.withArgs(endpoint, query, { authorization })).to.have.been.calledOnce() + expect(rootStore.user.personalization.projectPreferences.loadingState).to.equal(asyncStates.success) + rootStore.user.personalization.projectPreferences.reset() + }) + + it('should store the UPP resource', async function () { + expect(rootStore.user.personalization.projectPreferences).to.deep.equal(initialState) + await rootStore.user.personalization.projectPreferences.fetchResource() + const storedUPP = Object.assign({}, upp, { error: undefined, loadingState: asyncStates.success }) + expect(rootStore.user.personalization.projectPreferences).to.deep.equal(storedUPP) + }) + + it('should set the total classification count on the parent node', function () { + expect(rootStore.user.personalization.totalClassificationCount).to.equal(23) + }) + }) + + describe('when there are no user project preferences in the response', function () { + let nockScope + before(function () { + nockScope = nock('https://panoptes-staging.zooniverse.org/api') + .persist() + .get('/project_preferences') + .query(true) + .reply(200, { + project_preferences: [] + }) + .get('/collections') // This is to prevent the collections store from making real requests + .query(true) + .reply(200) + .post('/collections') + .query(true) + .reply(200) + }) + + after(function () { + nock.cleanAll() + rootStore.user.personalization.projectPreferences.reset() + nockScope = null + }) + + it('should not apply the UPP resource', async function () { + expect(rootStore.user.personalization.projectPreferences.loadingState).to.equal(asyncStates.initialized) + await rootStore.user.personalization.projectPreferences.fetchResource() + expect(rootStore.user.personalization.projectPreferences.loadingState).to.equal(asyncStates.success) + expect(rootStore.user.personalization.projectPreferences.id).to.be.undefined() + }) + + it('should not set the total classification count on the parent node', function () { + expect(rootStore.user.personalization.totalClassificationCount).to.equal(0) + }) + }) + + describe('when the request errors', function () { + let nockScope + before(function () { + nockScope = nock('https://panoptes-staging.zooniverse.org/api') + .persist() + .get('/project_preferences') + .query(true) + .replyWithError('Error!') + .get('/collections') // This is to prevent the collections store from making real requests + .query(true) + .reply(200) + .post('/collections') + .query(true) + .reply(200) + + }) + + after(function () { + rootStore.user.personalization.projectPreferences.reset() + nock.cleanAll() + nockScope = null + }) + + it('should store the error', async function () { + expect(rootStore.user.personalization.projectPreferences.loadingState).to.equal(asyncStates.initialized) + expect(rootStore.user.personalization.projectPreferences.error).to.be.undefined() + await rootStore.user.personalization.projectPreferences.fetchResource() + expect(rootStore.user.personalization.projectPreferences.error.message).to.equal('Error!') + expect(rootStore.user.personalization.projectPreferences.loadingState).to.equal(asyncStates.error) + }) + }) + + describe('when the response errors', function () { + let nockScope + before(function () { + nockScope = nock('https://panoptes-staging.zooniverse.org/api') + .persist() + .get('/project_preferences') + .query(true) + .reply(401, { + project_preferences: [] + }) + .get('/collections') // This is to prevent the collections store from making real requests + .query(true) + .reply(200) + .post('/collections') + .query(true) + .reply(200) + + rootStore.user.personalization.projectPreferences.reset() + }) + + after(function () { + nockScope = null + }) + + it('should store the error', async function () { + expect(rootStore.user.personalization.projectPreferences.loadingState).to.equal(asyncStates.initialized) + expect(rootStore.user.personalization.projectPreferences.error).to.be.undefined() + await rootStore.user.personalization.projectPreferences.fetchResource() + expect(rootStore.user.personalization.projectPreferences.error.message).to.equal('Unauthorized') + expect(rootStore.user.personalization.projectPreferences.loadingState).to.equal(asyncStates.error) + }) + }) + }) }) \ No newline at end of file diff --git a/packages/app-project/stores/YourStats.js b/packages/app-project/stores/YourStats.js index 8864d6856c..8d77f2949e 100644 --- a/packages/app-project/stores/YourStats.js +++ b/packages/app-project/stores/YourStats.js @@ -1,10 +1,7 @@ import asyncStates from '@zooniverse/async-states' -import { panoptes } from '@zooniverse/panoptes-js' import { GraphQLClient } from 'graphql-request' import _ from 'lodash' -import { DateTime } from 'luxon' -import { autorun } from 'mobx' -import { addDisposer, flow, getRoot, types } from 'mobx-state-tree' +import { flow, getRoot, types } from 'mobx-state-tree' import auth from 'panoptes-client/lib/auth' export const statsClient = new GraphQLClient('https://graphql-stats.zooniverse.org/graphql') @@ -33,45 +30,9 @@ const YourStats = types error: types.maybeNull(types.frozen({})), loadingState: types.optional(types.enumeration('state', asyncStates.values), asyncStates.initialized), thisWeek: types.array(Count), - totalCount: types.optional(types.number, 0) }) - .volatile(self => ({ - sessionCount: 0 - })) - - .views(self => ({ - get counts () { - const todaysDate = new Date() - let today - try { - const todaysCount = self.thisWeek.length === 7 - ? self.thisWeek[todaysDate.getDay() - 1].count - : 0 - today = todaysCount + self.sessionCount - } catch (error) { - today = 0 - } - - return { - today, - total: self.totalCount - } - } - })) - .actions(self => { - function createProjectObserver () { - const projectDisposer = autorun(() => { - const { project, user } = getRoot(self) - if (project.id && user.id) { - self.fetchActivityCount() - self.fetchDailyCounts() - } - }) - addDisposer(self, projectDisposer) - } - function calculateWeeklyStats (dailyCounts) { /* Calculate daily stats for this week, starting last Monday. @@ -94,31 +55,6 @@ const YourStats = types } return { - afterAttach () { - createProjectObserver() - }, - - fetchActivityCount: flow(function * fetchActivityCount () { - const { project, user } = getRoot(self) - self.loadingState = asyncStates.loading - try { - const token = yield auth.checkBearerToken() - const authorization = `Bearer ${token}` - const query = { - project_id: project.id, - user_id: user.id - } - // TODO: this should really share the UPP that's being requested by the classifier. - const response = yield panoptes.get('/project_preferences', query, { authorization }) - const [ preferences ] = response.body.project_preferences - self.totalCount = preferences ? preferences.activity_count : 0 - } catch (error) { - console.error(error) - self.error = error - self.loadingState = asyncStates.error - } - }), - fetchDailyCounts: flow(function * fetchDailyCounts () { const { project, user } = getRoot(self) self.loadingState = asyncStates.loading @@ -151,12 +87,7 @@ const YourStats = types dailyCounts = [] } self.thisWeek = calculateWeeklyStats(dailyCounts) - }), - - increment () { - self.sessionCount = self.sessionCount + 1 - self.totalCount = self.totalCount + 1 - } + }) } }) diff --git a/packages/app-project/stores/YourStats.spec.js b/packages/app-project/stores/YourStats.spec.js index dbd3ab09b1..6dd8e47058 100644 --- a/packages/app-project/stores/YourStats.spec.js +++ b/packages/app-project/stores/YourStats.spec.js @@ -1,12 +1,10 @@ -import { expect } from 'chai' import sinon from 'sinon' -import auth from 'panoptes-client/lib/auth' - +import nock from 'nock' import initStore from './initStore' -import YourStats, { statsClient } from './YourStats' +import { statsClient } from './YourStats' describe('Stores > YourStats', function () { - let rootStore + let rootStore, nockScope const project = { id: '2', display_name: 'Hello', @@ -15,15 +13,7 @@ describe('Stores > YourStats', function () { before(function () { sinon.stub(console, 'error') - const mockResponse = { - body: { - project_preferences: [ - { - activity_count: 23 - } - ] - } - } + const MOCK_DAILY_COUNTS = [ { count: 12, period: '2019-09-29' }, { count: 12, period: '2019-09-30' }, @@ -34,24 +24,36 @@ describe('Stores > YourStats', function () { { count: 8, period: '2019-10-05' }, { count: 15, period: '2019-10-06' } ] + nockScope = nock('https://panoptes-staging.zooniverse.org/api') + .persist() + .get('/project_preferences') + .query(true) + .reply(200, { + project_preferences: [ + { activity_count: 23 } + ] + }) + .get('/collections') + .query(true) + .reply(200) + .post('/collections') + .query(true) + .reply(200) rootStore = initStore(true, { project }) - sinon.stub(rootStore.client.panoptes, 'get').callsFake(() => Promise.resolve(mockResponse)) - sinon.stub(rootStore.client.panoptes, 'post').callsFake(() => Promise.resolve({})) sinon.stub(statsClient, 'request').callsFake(() => Promise.resolve({ statsCount: MOCK_DAILY_COUNTS })) }) after(function () { console.error.restore() - rootStore.client.panoptes.get.restore() - rootStore.client.panoptes.post.restore() statsClient.request.restore() + nock.cleanAll() }) it('should exist', function () { - expect(rootStore.yourStats).to.be.ok() + expect(rootStore.user.personalization.stats).to.be.ok() }) - describe('with a project and user', function () { + describe('Actions > fetchDailyCounts', function () { let clock before(function () { @@ -60,29 +62,12 @@ describe('Stores > YourStats', function () { id: '123', login: 'test.user' } - sinon.stub(rootStore.collections, 'fetchFavourites') + rootStore.user.set(user) }) after(function () { clock.restore() - rootStore.client.panoptes.get.resetHistory() - rootStore.client.panoptes.post.resetHistory() - rootStore.collections.fetchFavourites.restore() - }) - - it('should request user preferences', function () { - const authorization = 'Bearer ' - const endpoint = '/project_preferences' - const query = { - project_id: '2', - user_id: '123' - } - expect(rootStore.client.panoptes.get.withArgs(endpoint, query, { authorization })).to.have.been.calledOnce() - }) - - it('should store your activity count', function () { - expect(rootStore.yourStats.totalCount).to.equal(23) }) it('should request user statistics', function () { @@ -98,156 +83,21 @@ describe('Stores > YourStats', function () { count } }` + expect(statsClient.request).to.have.been.calledOnceWith(query.replace(/\s+/g, ' ')) }) describe('weekly classification stats', function () { it('should be created', function () { - expect(rootStore.yourStats.thisWeek.length).to.equal(7) + expect(rootStore.user.personalization.stats.thisWeek.length).to.equal(7) }) it('should start on Monday', function () { - expect(rootStore.yourStats.thisWeek[0]).to.deep.equal({ count: 12, period: '2019-09-30' }) + expect(rootStore.user.personalization.stats.thisWeek[0]).to.deep.equal({ count: 12, period: '2019-09-30' }) }) it('should end on Sunday', function () { - expect(rootStore.yourStats.thisWeek[6]).to.deep.equal({ count: 15, period: '2019-10-06' }) - }) - }) - - describe('incrementing your classification count', function () { - before(function () { - rootStore.yourStats.increment() - }) - - it('should add 1 to your total count', function () { - expect(rootStore.yourStats.totalCount).to.equal(24) - }) - - it('should add 1 to your session count', function () { - expect(rootStore.yourStats.sessionCount).to.equal(1) - }) - }) - }) - - describe('with a project and anonymous user', function () { - before(function () { - rootStore = initStore(true, { project }) - }) - - it('should not request user preferences from Panoptes', function () { - expect(rootStore.client.panoptes.get).to.have.not.been.called() - }) - - it('should start counting from 0', function () { - expect(rootStore.yourStats.totalCount).to.equal(0) - }) - - describe('incrementing your classification count', function () { - before(function () { - rootStore.yourStats.increment() - }) - - it('should add 1 to your total count', function () { - expect(rootStore.yourStats.totalCount).to.equal(1) - }) - }) - }) - - describe('when Zooniverse auth is down.', function () { - before(function () { - rootStore = initStore(true, { project }) - const user = { - id: '123', - login: 'test.user' - } - sinon.stub(auth, 'checkBearerToken').callsFake(() => Promise.reject(new Error('Auth is not available'))) - sinon.stub(rootStore.collections, 'fetchFavourites') - rootStore.user.set(user) - rootStore.yourStats.increment() - }) - - after(function () { - auth.checkBearerToken.restore() - rootStore.collections.fetchFavourites.restore() - }) - - it('should count session classifications from 0', function () { - expect(rootStore.yourStats.totalCount).to.equal(1) - }) - }) - - describe('on Panoptes API errors', function () { - before(function () { - rootStore = initStore(true, { project }) - const user = { - id: '123', - login: 'test.user' - } - rootStore.client.panoptes.get.callsFake(() => Promise.reject(new Error('Panoptes is not available'))) - sinon.stub(rootStore.collections, 'fetchFavourites') - rootStore.user.set(user) - rootStore.yourStats.increment() - }) - - after(function () { - rootStore.collections.fetchFavourites.restore() - }) - - it('should count session classifications from 0', function () { - expect(rootStore.yourStats.totalCount).to.equal(1) - }) - }) - - describe('counts view', function () { - it('should return the expected counts with no data', function () { - const statsStore = YourStats.create() - expect(statsStore.counts).to.deep.equal({ - today: 0, - total: 0 - }) - }) - - describe('total count', function () { - it('should get the total count from the store `totalCount` value', function () { - const statsStore = YourStats.create({ totalCount: 42 }) - expect(statsStore.counts.total).to.equal(42) - }) - }) - - describe('today\'s count', function () { - let clock - - before(function () { - clock = sinon.useFakeTimers({ now: new Date(2019, 9, 1, 12), toFake: ['Date'] }) - }) - - after(function () { - clock.restore() - }) - - it('should get today\'s count from the store\'s counts for this week', function () { - const MOCK_DAILY_COUNTS = [ - { count: 12, period: '2019-09-30T00:00:00Z' }, - { count: 13, period: '2019-10-01T00:00:00Z' }, - { count: 14, period: '2019-10-02T00:00:00Z' }, - { count: 10, period: '2019-10-03T00:00:00Z' }, - { count: 11, period: '2019-10-04T00:00:00Z' }, - { count: 8, period: '2019-10-05T00:00:00Z' }, - { count: 15, period: '2019-10-06T00:00:00Z' } - ] - const statsStore = YourStats.create({ thisWeek: MOCK_DAILY_COUNTS }) - expect(statsStore.counts.today).to.equal(MOCK_DAILY_COUNTS[1].count) - }) - - it('should be `0` if there are no classifications today', function () { - const MOCK_DAILY_COUNTS = [ - { count: 12, period: '2019-01-03T00:00:00Z' }, - { count: 13, period: '2019-01-02T00:00:00Z' }, - { count: 14, period: '2019-01-01T00:00:00Z' } - ] - const statsStore = YourStats.create({ thisWeek: MOCK_DAILY_COUNTS }) - expect(statsStore.counts.today).to.equal(0) + expect(rootStore.user.personalization.stats.thisWeek[6]).to.deep.equal({ count: 15, period: '2019-10-06' }) }) }) })