diff --git a/src/amo/api/recommendations.js b/src/amo/api/recommendations.js new file mode 100644 index 00000000000..cdf90625e3e --- /dev/null +++ b/src/amo/api/recommendations.js @@ -0,0 +1,28 @@ +/* @flow */ +import invariant from 'invariant'; + +import { callApi } from 'core/api'; +import type { ApiStateType } from 'core/reducers/api'; +import type { PaginatedApiResponse } from 'core/types/api'; +import type { ExternalAddonType } from 'core/types/addons'; + + +export type GetRecommendationsParams = {| + api: ApiStateType, + guid: string, + recommended: boolean, +|}; + +export const getRecommendations = ( + { api, guid, recommended }: GetRecommendationsParams +): Promise> => { + invariant(guid, 'A guid is required.'); + invariant(typeof recommended === 'boolean', 'recommended is required'); + + return callApi({ + auth: true, + endpoint: 'addons/recommendations/', + params: { guid, recommended }, + state: api, + }); +}; diff --git a/src/amo/reducers/recommendations.js b/src/amo/reducers/recommendations.js new file mode 100644 index 00000000000..47417a717e8 --- /dev/null +++ b/src/amo/reducers/recommendations.js @@ -0,0 +1,184 @@ +/* @flow */ +import invariant from 'invariant'; + +import { createInternalAddon } from 'core/reducers/addons'; +import type { AddonType, ExternalAddonType } from 'core/types/addons'; + +export const ABORT_FETCH_RECOMMENDATIONS: 'ABORT_FETCH_RECOMMENDATIONS' + = 'ABORT_FETCH_RECOMMENDATIONS'; +export const FETCH_RECOMMENDATIONS: 'FETCH_RECOMMENDATIONS' + = 'FETCH_RECOMMENDATIONS'; +export const LOAD_RECOMMENDATIONS: 'LOAD_RECOMMENDATIONS' + = 'LOAD_RECOMMENDATIONS'; + +export type FallbackReasonType = 'no_results' | 'timeout'; +export type OutcomeType = 'curated' | 'recommended' | 'recommended_fallback'; + +export type Recommendations = {| + addons: Array | null, + fallbackReason: FallbackReasonType | null, + loading: boolean, + outcome: OutcomeType | null, +|}; + +export type RecommendationsState = {| + byGuid: { + [guid: string]: Recommendations, + }, +|}; + +export const initialState: RecommendationsState = { + byGuid: {}, +}; + +export type AbortFetchRecommendationsParams = {| + guid: string, +|}; + +type AbortFetchRecommendationsAction = {| + type: typeof ABORT_FETCH_RECOMMENDATIONS, + payload: AbortFetchRecommendationsParams, +|}; + +export const abortFetchRecommendations = ({ + guid, +}: AbortFetchRecommendationsParams): AbortFetchRecommendationsAction => { + invariant(guid, 'guid is required'); + return { + type: ABORT_FETCH_RECOMMENDATIONS, + payload: { guid }, + }; +}; + +type FetchRecommendationsParams = {| + errorHandlerId: string, + guid: string, + recommended: boolean, +|}; + +export type FetchRecommendationsAction = {| + type: typeof FETCH_RECOMMENDATIONS, + payload: FetchRecommendationsParams, +|}; + +export const fetchRecommendations = ({ + errorHandlerId, + guid, + recommended, +}: FetchRecommendationsParams): FetchRecommendationsAction => { + invariant(errorHandlerId, 'errorHandlerId is required'); + invariant(guid, 'guid is required'); + invariant(typeof recommended === 'boolean', 'recommended is required'); + + return { + type: FETCH_RECOMMENDATIONS, + payload: { errorHandlerId, guid, recommended }, + }; +}; + +export type LoadRecommendationsParams = {| + addons: Array, + fallbackReason: string, + guid: string, + outcome: string, +|}; + +type LoadRecommendationsAction = {| + type: typeof LOAD_RECOMMENDATIONS, + payload: LoadRecommendationsParams, +|}; + +export const loadRecommendations = ({ + addons, + fallbackReason, + guid, + outcome, +}: LoadRecommendationsParams): LoadRecommendationsAction => { + invariant(addons, 'addons is required'); + invariant(guid, 'guid is required'); + invariant(outcome, 'outcome is required'); + + return { + type: LOAD_RECOMMENDATIONS, + payload: { addons, guid, outcome, fallbackReason }, + }; +}; + +type GetRecommendationsByGuidParams = {| + guid: string, + state: RecommendationsState, +|}; + +export const getRecommendationsByGuid = ( + { guid, state }: GetRecommendationsByGuidParams +): Recommendations | null => { + invariant(guid, 'guid is required'); + invariant(state, 'state is required'); + + return state.byGuid[guid] || null; +}; + +type Action = + | AbortFetchRecommendationsAction + | FetchRecommendationsAction + | LoadRecommendationsAction; + +const reducer = ( + state: RecommendationsState = initialState, + action: Action +): RecommendationsState => { + switch (action.type) { + case ABORT_FETCH_RECOMMENDATIONS: + return { + ...state, + byGuid: { + ...state.byGuid, + [action.payload.guid]: { + addons: null, + fallbackReason: null, + loading: false, + outcome: null, + }, + }, + }; + + case FETCH_RECOMMENDATIONS: + return { + ...state, + byGuid: { + ...state.byGuid, + [action.payload.guid]: { + addons: null, + fallbackReason: null, + loading: true, + outcome: null, + }, + }, + }; + + case LOAD_RECOMMENDATIONS: { + const { fallbackReason, guid, outcome } = action.payload; + + const addons = action.payload.addons + .map((addon) => createInternalAddon(addon)); + + return { + ...state, + byGuid: { + ...state.byGuid, + [guid]: { + addons, + fallbackReason, + loading: false, + outcome, + }, + }, + }; + } + + default: + return state; + } +}; + +export default reducer; diff --git a/src/amo/sagas/index.js b/src/amo/sagas/index.js index 5b756bd99ab..aad8e3bb11d 100644 --- a/src/amo/sagas/index.js +++ b/src/amo/sagas/index.js @@ -9,6 +9,7 @@ import categories from 'amo/sagas/categories'; import collections from 'amo/sagas/collections'; import home from 'amo/sagas/home'; import landing from 'amo/sagas/landing'; +import recommendations from 'amo/sagas/recommendations'; import reviews from 'amo/sagas/reviews'; import abuse from 'core/sagas/abuse'; import addons from 'core/sagas/addons'; @@ -31,6 +32,7 @@ export default function* rootSaga() { fork(home), fork(landing), fork(languageTools), + fork(recommendations), fork(reviews), fork(search), fork(userAbuseReports), diff --git a/src/amo/sagas/recommendations.js b/src/amo/sagas/recommendations.js new file mode 100644 index 00000000000..3073fbd9389 --- /dev/null +++ b/src/amo/sagas/recommendations.js @@ -0,0 +1,48 @@ +/* @flow */ +import { call, put, select, takeLatest } from 'redux-saga/effects'; +import { + FETCH_RECOMMENDATIONS, + abortFetchRecommendations, + loadRecommendations, +} from 'amo/reducers/recommendations'; +import * as api from 'amo/api/recommendations'; +import log from 'core/logger'; +import { createErrorHandler, getState } from 'core/sagas/utils'; +import type { GetRecommendationsParams } from 'amo/api/recommendations'; +import type { + FetchRecommendationsAction, +} from 'amo/reducers/recommendations'; + + +export function* fetchRecommendations({ + payload: { errorHandlerId, guid, recommended }, +}: FetchRecommendationsAction): Generator { + const errorHandler = createErrorHandler(errorHandlerId); + yield put(errorHandler.createClearingAction()); + + try { + const state = yield select(getState); + + const params: GetRecommendationsParams = { + api: state.api, guid, recommended, + }; + const recommendations = yield call(api.getRecommendations, params); + const { fallback_reason: fallbackReason, outcome, results: addons } + = recommendations; + + yield put(loadRecommendations({ + addons, + fallbackReason, + guid, + outcome, + })); + } catch (error) { + log.warn(`Failed to recommendations: ${error}`); + yield put(errorHandler.createErrorAction(error)); + yield put(abortFetchRecommendations({ guid })); + } +} + +export default function* recommendationsSaga(): Generator { + yield takeLatest(FETCH_RECOMMENDATIONS, fetchRecommendations); +} diff --git a/src/amo/store.js b/src/amo/store.js index f8fc479e46b..e7681d19aef 100644 --- a/src/amo/store.js +++ b/src/amo/store.js @@ -7,6 +7,7 @@ import addonsByAuthors from 'amo/reducers/addonsByAuthors'; import collections from 'amo/reducers/collections'; import home from 'amo/reducers/home'; import landing from 'amo/reducers/landing'; +import recommendations from 'amo/reducers/recommendations'; import reviews from 'amo/reducers/reviews'; import userAbuseReports from 'amo/reducers/userAbuseReports'; import users from 'amo/reducers/users'; @@ -50,6 +51,7 @@ export default function createStore({ installations, landing, languageTools, + recommendations, redirectTo, reviews, routing, diff --git a/tests/unit/amo/api/test_recommendations.js b/tests/unit/amo/api/test_recommendations.js new file mode 100644 index 00000000000..006344ba945 --- /dev/null +++ b/tests/unit/amo/api/test_recommendations.js @@ -0,0 +1,35 @@ +import * as api from 'core/api'; +import { getRecommendations } from 'amo/api/recommendations'; +import { createApiResponse } from 'tests/unit/helpers'; +import { dispatchClientMetadata } from 'tests/unit/amo/helpers'; + + +describe(__filename, () => { + it('calls the recommendations API', async () => { + const mockApi = sinon.mock(api); + const apiState = dispatchClientMetadata().store.getState().api; + + const params = { + guid: 'addon-guid', + recommended: true, + }; + + mockApi + .expects('callApi') + .withArgs({ + auth: true, + endpoint: + 'addons/recommendations/', + params, + state: apiState, + }) + .once() + .returns(createApiResponse()); + + await getRecommendations({ + api: apiState, + ...params, + }); + mockApi.verify(); + }); +}); diff --git a/tests/unit/amo/reducers/test_recommendations.js b/tests/unit/amo/reducers/test_recommendations.js new file mode 100644 index 00000000000..885f51b30b5 --- /dev/null +++ b/tests/unit/amo/reducers/test_recommendations.js @@ -0,0 +1,82 @@ +import reducer, { + abortFetchRecommendations, + fetchRecommendations, + getRecommendationsByGuid, + initialState, + loadRecommendations, +} from 'amo/reducers/recommendations'; +import { createInternalAddon } from 'core/reducers/addons'; +import { createStubErrorHandler } from 'tests/unit/helpers'; +import { fakeAddon } from 'tests/unit/amo/helpers'; + + +describe(__filename, () => { + it('initializes properly', () => { + const state = reducer(undefined, {}); + expect(state).toEqual(initialState); + }); + + it('ignores unrelated actions', () => { + const state = reducer(initialState, { type: 'UNRELATED_ACTION' }); + expect(state).toEqual(initialState); + }); + + it('sets the loading flag when fetching recommendations', () => { + const guid = 'some-guid'; + const state = reducer(undefined, fetchRecommendations({ + errorHandlerId: createStubErrorHandler().id, + guid, + recommended: true, + })); + + expect(state.byGuid[guid].loading).toEqual(true); + expect(state.byGuid[guid].addons).toEqual(null); + }); + + it('loads recommendations', () => { + const addons = [fakeAddon, fakeAddon]; + const fallbackReason = 'timeout'; + const guid = 'some-guid'; + const outcome = 'recommended_fallback'; + const state = reducer(undefined, loadRecommendations({ + addons, + fallbackReason, + guid, + outcome, + })); + + const expectedAddons = addons.map((addon) => createInternalAddon(addon)); + + const loadedRecommendations = getRecommendationsByGuid({ guid, state }); + + expect(loadedRecommendations).toEqual({ + addons: expectedAddons, + fallbackReason, + loading: false, + outcome, + }); + }); + + it('resets the loading flag when fetching is aborted', () => { + const guid = 'some-guid'; + const state = reducer(undefined, fetchRecommendations({ + errorHandlerId: createStubErrorHandler().id, + guid, + recommended: true, + })); + + expect(state.byGuid[guid].loading).toEqual(true); + + const newState = reducer(state, abortFetchRecommendations({ guid })); + expect(newState.byGuid[guid].loading).toEqual(false); + }); + + describe('getRecommendationsByGuid', () => { + it('returns null if no recommendations exist for the guid', () => { + const state = reducer(undefined, {}); + const guid = 'a-non-existent-guid'; + + expect(getRecommendationsByGuid({ guid, state })).toEqual(null); + }); + }); +}); diff --git a/tests/unit/amo/sagas/test_recommendations.js b/tests/unit/amo/sagas/test_recommendations.js new file mode 100644 index 00000000000..292666db119 --- /dev/null +++ b/tests/unit/amo/sagas/test_recommendations.js @@ -0,0 +1,104 @@ +import SagaTester from 'redux-saga-tester'; + +import * as recommendationsApi from 'amo/api/recommendations'; +import recommendationsReducer, { + abortFetchRecommendations, + fetchRecommendations, + loadRecommendations, +} from 'amo/reducers/recommendations'; +import recommendationsSaga from 'amo/sagas/recommendations'; +import apiReducer from 'core/reducers/api'; +import { createStubErrorHandler } from 'tests/unit/helpers'; +import { + fakeAddon, + dispatchClientMetadata, +} from 'tests/unit/amo/helpers'; + + +describe(__filename, () => { + let errorHandler; + let mockApi; + let sagaTester; + + beforeEach(() => { + const clientData = dispatchClientMetadata(); + errorHandler = createStubErrorHandler(); + mockApi = sinon.mock(recommendationsApi); + sagaTester = new SagaTester({ + initialState: clientData.state, + reducers: { + api: apiReducer, + recommendations: recommendationsReducer, + }, + }); + sagaTester.start(recommendationsSaga); + }); + + const guid = 'some-guid'; + const recommended = true; + + function _fetchRecommendations(params) { + sagaTester.dispatch(fetchRecommendations({ + errorHandlerId: errorHandler.id, + ...params, + })); + } + + it('calls the API to fetch recommendations', async () => { + const state = sagaTester.getState(); + + const recommendations = { + outcome: 'recommended_fallback', + fallback_reason: 'timeout', + results: [fakeAddon, fakeAddon], + }; + + mockApi + .expects('getRecommendations') + .withArgs({ + api: state.api, + guid, + recommended, + }) + .once() + .returns(Promise.resolve(recommendations)); + + _fetchRecommendations({ guid, recommended }); + + const expectedLoadAction = loadRecommendations({ + addons: recommendations.results, + fallbackReason: recommendations.fallback_reason, + guid, + outcome: recommendations.outcome, + }); + + const loadAction = await sagaTester.waitFor(expectedLoadAction.type); + expect(loadAction).toEqual(expectedLoadAction); + mockApi.verify(); + }); + + it('clears the error handler', async () => { + _fetchRecommendations({ guid, recommended }); + + const expectedAction = errorHandler.createClearingAction(); + + const action = await sagaTester.waitFor(expectedAction.type); + expect(action).toEqual(expectedAction); + }); + + it('dispatches an error and aborts the fetching', async () => { + const error = new Error('some API error maybe'); + + mockApi + .expects('getRecommendations') + .once() + .returns(Promise.reject(error)); + + _fetchRecommendations({ guid, recommended }); + + const expectedAction = errorHandler.createErrorAction(error); + const action = await sagaTester.waitFor(expectedAction.type); + expect(expectedAction).toEqual(action); + expect(sagaTester.getCalledActions()[3]).toEqual(abortFetchRecommendations({ guid })); + }); +}); diff --git a/tests/unit/amo/test_store.js b/tests/unit/amo/test_store.js index eaecff8e120..8ccd16bf144 100644 --- a/tests/unit/amo/test_store.js +++ b/tests/unit/amo/test_store.js @@ -21,6 +21,7 @@ describe(__filename, () => { 'installations', 'landing', 'languageTools', + 'recommendations', 'redirectTo', 'reviews', 'routing',