Add reducer/saga/api logic for reporting a collection (#12595)

This commit is contained in:
William Durand 2023-11-14 13:59:27 +01:00 коммит произвёл GitHub
Родитель 3f52cb7a1a
Коммит 2f1458ab4f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
10 изменённых файлов: 703 добавлений и 2 удалений

Просмотреть файл

@ -336,7 +336,7 @@
"bundlewatch": [
{
"path": "./dist/static/amo-!(i18n-)*.js",
"maxSize": "355 kB"
"maxSize": "356 kB"
},
{
"path": "./dist/static/amo-i18n-*.js",

Просмотреть файл

@ -184,3 +184,56 @@ export function reportRating({
apiState: api,
});
}
export type ReportCollectionParams = {|
api: ApiState,
collectionId: number,
message: string | null,
reason: string | null,
reporterName: string | null,
reporterEmail: string | null,
auth: boolean,
|};
export type ReportCollectionResponse = {|
reporter: AbuseReporter | null,
reporter_name: string | null,
reporter_email: string | null,
collection: {|
id: number,
|},
message: string,
reason: string | null,
|};
// See: https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html#submitting-a-collection-abuse-report
export function reportCollection({
api,
collectionId,
message,
reason,
reporterName,
reporterEmail,
auth,
}: ReportCollectionParams): Promise<ReportCollectionResponse> {
if (!reason) {
invariant(
message?.trim(),
"message is required when reason isn't specified",
);
}
return callApi({
auth,
endpoint: 'abuse/report/collection',
method: 'POST',
body: {
collection: collectionId,
message,
reason,
reporter_name: reporterName,
reporter_email: reporterEmail,
},
apiState: api,
});
}

Просмотреть файл

@ -0,0 +1,160 @@
/* @flow */
import invariant from 'invariant';
type CollectionAbuseReport = {|
isSubmitting: boolean,
hasSubmitted: boolean,
|};
export type CollectionAbuseReportsState = {|
byCollectionId: {
[collectionId: number]: CollectionAbuseReport,
},
|};
export const SEND_COLLECTION_ABUSE_REPORT: 'SEND_COLLECTION_ABUSE_REPORT' =
'SEND_COLLECTION_ABUSE_REPORT';
export const LOAD_COLLECTION_ABUSE_REPORT: 'LOAD_COLLECTION_ABUSE_REPORT' =
'LOAD_COLLECTION_ABUSE_REPORT';
export const ABORT_COLLECTION_ABUSE_REPORT: 'ABORT_COLLECTION_ABUSE_REPORT' =
'ABORT_COLLECTION_ABUSE_REPORT';
type SendCollectionAbuseReportParams = {|
auth: boolean,
errorHandlerId: string,
message: string | null,
collectionId: number,
reason: string | null,
reporterEmail: string | null,
reporterName: string | null,
|};
export type SendCollectionAbuseReportAction = {|
type: typeof SEND_COLLECTION_ABUSE_REPORT,
payload: SendCollectionAbuseReportParams,
|};
export const sendCollectionAbuseReport = ({
auth,
errorHandlerId,
message,
collectionId,
reason,
reporterEmail,
reporterName,
}: SendCollectionAbuseReportParams): SendCollectionAbuseReportAction => {
invariant(errorHandlerId, 'errorHandlerId is required');
invariant(collectionId, 'collectionId is required');
return {
type: SEND_COLLECTION_ABUSE_REPORT,
payload: {
auth,
errorHandlerId,
message,
collectionId,
reason,
reporterEmail,
reporterName,
},
};
};
type AbortCollectionAbuseReportParams = {|
collectionId: number,
|};
export type AbortCollectionAbuseReportAction = {|
type: typeof ABORT_COLLECTION_ABUSE_REPORT,
payload: AbortCollectionAbuseReportParams,
|};
export const abortCollectionAbuseReport = ({
collectionId,
}: AbortCollectionAbuseReportParams): AbortCollectionAbuseReportAction => {
invariant(collectionId, 'collectionId is required');
return {
type: ABORT_COLLECTION_ABUSE_REPORT,
payload: { collectionId },
};
};
type LoadCollectionAbuseReportParams = {|
collectionId: number,
|};
export type LoadCollectionAbuseReportAction = {|
type: typeof LOAD_COLLECTION_ABUSE_REPORT,
payload: LoadCollectionAbuseReportParams,
|};
export const loadCollectionAbuseReport = ({
collectionId,
}: LoadCollectionAbuseReportParams): LoadCollectionAbuseReportAction => {
invariant(collectionId, 'collectionId is required');
return {
type: LOAD_COLLECTION_ABUSE_REPORT,
payload: { collectionId },
};
};
const updateCollection = (
collectionAbuseReportsState: CollectionAbuseReportsState,
collectionId: number,
report: CollectionAbuseReport,
): CollectionAbuseReportsState => {
return {
...collectionAbuseReportsState,
byCollectionId: {
...collectionAbuseReportsState.byCollectionId,
[collectionId]: {
...collectionAbuseReportsState.byCollectionId[collectionId],
...report,
},
},
};
};
export const initialState: CollectionAbuseReportsState = {
byCollectionId: {},
};
export default function collectionAbuseReportsReducer(
// eslint-disable-next-line default-param-last
state: CollectionAbuseReportsState = initialState,
action:
| SendCollectionAbuseReportAction
| LoadCollectionAbuseReportAction
| AbortCollectionAbuseReportAction,
): CollectionAbuseReportsState {
switch (action.type) {
case SEND_COLLECTION_ABUSE_REPORT: {
const { collectionId } = action.payload;
return updateCollection(state, collectionId, {
isSubmitting: true,
hasSubmitted: false,
});
}
case LOAD_COLLECTION_ABUSE_REPORT: {
const { collectionId } = action.payload;
return updateCollection(state, collectionId, {
isSubmitting: false,
hasSubmitted: true,
});
}
case ABORT_COLLECTION_ABUSE_REPORT: {
const { collectionId } = action.payload;
return updateCollection(state, collectionId, {
isSubmitting: false,
hasSubmitted: false,
});
}
default:
return state;
}
}

Просмотреть файл

@ -0,0 +1,58 @@
/* @flow */
import { call, put, select, takeLatest } from 'redux-saga/effects';
import {
SEND_COLLECTION_ABUSE_REPORT,
loadCollectionAbuseReport,
abortCollectionAbuseReport,
} from 'amo/reducers/collectionAbuseReports';
import { reportCollection as reportCollectionApi } from 'amo/api/abuse';
import log from 'amo/logger';
import { createErrorHandler, getState } from 'amo/sagas/utils';
import type { SendCollectionAbuseReportAction } from 'amo/reducers/collectionAbuseReports';
import type { ReportCollectionParams } from 'amo/api/abuse';
import type { Saga } from 'amo/types/sagas';
export function* reportCollection({
payload: {
auth,
errorHandlerId,
message,
reason,
reporterEmail,
reporterName,
collectionId,
},
}: SendCollectionAbuseReportAction): Saga {
const errorHandler = createErrorHandler(errorHandlerId);
yield put(errorHandler.createClearingAction());
try {
const state = yield select(getState);
const params: ReportCollectionParams = {
api: state.api,
auth,
message,
reason: reason || null,
reporterName: reporterName || null,
reporterEmail: reporterEmail || null,
collectionId,
};
const response = yield call(reportCollectionApi, params);
yield put(
loadCollectionAbuseReport({ collectionId: response.collection.id }),
);
} catch (error) {
log.warn(`Reporting collection for abuse failed: ${error}`);
yield put(errorHandler.createErrorAction(error));
yield put(abortCollectionAbuseReport({ collectionId }));
}
}
export default function* collectionAbcollectioneportsSaga(): Saga {
yield takeLatest(SEND_COLLECTION_ABUSE_REPORT, reportCollection);
}

Просмотреть файл

@ -4,6 +4,7 @@ import { all, fork } from 'redux-saga/effects';
import addonsByAuthors from 'amo/sagas/addonsByAuthors';
import blocks from 'amo/sagas/blocks';
import collections from 'amo/sagas/collections';
import collectionAbuseReports from 'amo/sagas/collectionAbuseReports';
import home from 'amo/sagas/home';
import landing from 'amo/sagas/landing';
import recommendations from 'amo/sagas/recommendations';
@ -31,6 +32,7 @@ export default function* rootSaga(): Saga {
fork(blocks),
fork(categories),
fork(collections),
fork(collectionAbuseReports),
fork(home),
fork(landing),
fork(languageTools),

Просмотреть файл

@ -10,6 +10,7 @@ import { configureStore } from '@reduxjs/toolkit';
import addonsByAuthors from 'amo/reducers/addonsByAuthors';
import collections from 'amo/reducers/collections';
import collectionAbuseReports from 'amo/reducers/collectionAbuseReports';
import blocks from 'amo/reducers/blocks';
import experiments from 'amo/reducers/experiments';
import home from 'amo/reducers/home';
@ -38,6 +39,7 @@ import suggestions from 'amo/reducers/suggestions';
import type { AddonsByAuthorsState } from 'amo/reducers/addonsByAuthors';
import type { BlocksState } from 'amo/reducers/blocks';
import type { CollectionsState } from 'amo/reducers/collections';
import type { CollectionAbuseReportsState } from 'amo/reducers/collectionAbuseReports';
import type { ExperimentsState } from 'amo/reducers/experiments';
import type { HomeState } from 'amo/reducers/home';
import type { LandingState } from 'amo/reducers/landing';
@ -123,6 +125,7 @@ type InternalAppState = {|
blocks: BlocksState,
categories: CategoriesState,
collections: CollectionsState,
collectionAbuseReports: CollectionAbuseReportsState,
errorPage: ErrorPageState,
errors: Object,
experiments: ExperimentsState,
@ -179,6 +182,7 @@ export const reducers: AppReducersType = {
blocks,
categories,
collections,
collectionAbuseReports,
errors,
errorPage,
experiments,

Просмотреть файл

@ -1,5 +1,10 @@
import * as api from 'amo/api';
import { reportAddon, reportUser, reportRating } from 'amo/api/abuse';
import {
reportAddon,
reportUser,
reportRating,
reportCollection,
} from 'amo/api/abuse';
import {
createApiResponse,
createFakeAddonAbuseReport,
@ -295,4 +300,103 @@ describe(__filename, () => {
},
);
});
describe('reportCollection', () => {
const mockResponse = ({ collectionId = 123, ...otherProps } = {}) => {
return createApiResponse({
jsonData: {
reporter: null,
reporter_name: 'some reporter name',
reporter_email: 'some reporter email',
collection: {
id: collectionId,
},
message: '',
reason: 'illegal',
...otherProps,
},
});
};
it('calls the collection abuse report API', async () => {
const apiState = dispatchClientMetadata().store.getState().api;
const reason = 'other';
const reporterEmail = 'some-reporter-email';
const reporterName = 'some-reporter-name';
const collectionId = 1234;
mockApi
.expects('callApi')
.withArgs({
auth: true,
endpoint: 'abuse/report/collection',
method: 'POST',
body: {
collection: collectionId,
message: undefined,
reason,
reporter_email: reporterEmail,
reporter_name: reporterName,
},
apiState,
})
.once()
.returns(mockResponse());
await reportCollection({
api: apiState,
auth: true,
collectionId,
reason,
reporterEmail,
reporterName,
});
mockApi.verify();
});
it('allows the collection abuse report API to be called anonymously', async () => {
const apiState = dispatchClientMetadata().store.getState().api;
const collectionId = 1234;
const message = 'not a great collection';
mockApi
.expects('callApi')
.withArgs({
auth: false,
endpoint: 'abuse/report/collection',
method: 'POST',
body: {
collection: collectionId,
message,
reason: undefined,
reporter_email: undefined,
reporter_name: undefined,
},
apiState,
})
.once()
.returns(mockResponse());
await reportCollection({
api: apiState,
auth: false,
message,
collectionId,
});
mockApi.verify();
});
it.each([undefined, '', null, ' '])(
'throws when reason is not supplied and message is %s',
async (message) => {
const apiState = dispatchClientMetadata().store.getState().api;
await expect(() =>
reportCollection({ api: apiState, collectionId: 123, message }),
).toThrow(/message is required when reason isn't specified/);
},
);
});
});

Просмотреть файл

@ -0,0 +1,180 @@
import collectionAbuseReportsReducer, {
abortCollectionAbuseReport,
initialState,
loadCollectionAbuseReport,
sendCollectionAbuseReport,
} from 'amo/reducers/collectionAbuseReports';
describe(__filename, () => {
describe('reducer', () => {
it('initializes properly', () => {
const state = collectionAbuseReportsReducer(initialState, {
type: 'UNRELATED_ACTION',
});
expect(state).toEqual(initialState);
});
});
describe('abortCollectionAbuseReport', () => {
it('requires a collection ID', () => {
expect(() => {
abortCollectionAbuseReport({});
}).toThrow('collectionId is required');
});
it('resets a collection report', () => {
const collectionId = 123;
let state = collectionAbuseReportsReducer(
undefined,
sendCollectionAbuseReport({
collectionId,
errorHandlerId: 'some-error-handler-id',
}),
);
expect(state.byCollectionId[collectionId]).toMatchObject({
isSubmitting: true,
});
state = collectionAbuseReportsReducer(
state,
abortCollectionAbuseReport({ collectionId }),
);
expect(state.byCollectionId[collectionId]).toEqual({
hasSubmitted: false,
isSubmitting: false,
});
});
it('does not change the unrelated reports', () => {
const collectionId = 157;
const errorHandlerId = 'some-error-handler';
let state = collectionAbuseReportsReducer(
undefined,
sendCollectionAbuseReport({ collectionId, errorHandlerId }),
);
state = collectionAbuseReportsReducer(
state,
sendCollectionAbuseReport({ collectionId: 246, errorHandlerId }),
);
state = collectionAbuseReportsReducer(
state,
abortCollectionAbuseReport({ collectionId }),
);
expect(state.byCollectionId).toMatchObject({
157: {
isSubmitting: false,
hasSubmitted: false,
},
246: {
isSubmitting: true,
hasSubmitted: false,
},
});
});
});
describe('loadCollectionAbuseReport', () => {
it('requires a collection ID', () => {
expect(() => {
loadCollectionAbuseReport({});
}).toThrow('collectionId is required');
});
it('marks a collection report as submitted', () => {
const collectionId = 123;
const state = collectionAbuseReportsReducer(
undefined,
loadCollectionAbuseReport({
collectionId,
}),
);
expect(state.byCollectionId[collectionId]).toEqual({
hasSubmitted: true,
isSubmitting: false,
});
});
it('does not change the unrelated reports', () => {
const collectionId = 157;
const errorHandlerId = 'some-error-handler';
let state = collectionAbuseReportsReducer(
undefined,
sendCollectionAbuseReport({ collectionId, errorHandlerId }),
);
state = collectionAbuseReportsReducer(
state,
sendCollectionAbuseReport({ collectionId: 246, errorHandlerId }),
);
state = collectionAbuseReportsReducer(
state,
loadCollectionAbuseReport({ collectionId }),
);
expect(state.byCollectionId).toMatchObject({
157: {
isSubmitting: false,
hasSubmitted: true,
},
246: {
isSubmitting: true,
hasSubmitted: false,
},
});
});
});
describe('sendCollectionAbuseReport', () => {
it('requires a collection ID', () => {
expect(() => {
sendCollectionAbuseReport({ errorHandlerId: 'error-handler-id' });
}).toThrow('collectionId is required');
});
it('requires an error handler ID', () => {
expect(() => {
sendCollectionAbuseReport({ collectionId: 123 });
}).toThrow('errorHandlerId is required');
});
it('marks a collection report as being submitted', () => {
const collectionId = 123;
const state = collectionAbuseReportsReducer(
undefined,
sendCollectionAbuseReport({
collectionId,
errorHandlerId: 'some-error-handler-id',
}),
);
expect(state.byCollectionId[collectionId]).toEqual({
hasSubmitted: false,
isSubmitting: true,
});
});
it('can load multiple reports', () => {
const errorHandlerId = 'some-error-handler-id';
let state = collectionAbuseReportsReducer(
undefined,
sendCollectionAbuseReport({ collectionId: 1, errorHandlerId }),
);
state = collectionAbuseReportsReducer(
state,
sendCollectionAbuseReport({ collectionId: 246, errorHandlerId }),
);
expect(state.byCollectionId).toMatchObject({
1: expect.any(Object),
246: expect.any(Object),
});
});
});
});

Просмотреть файл

@ -0,0 +1,121 @@
import SagaTester from 'redux-saga-tester';
import * as api from 'amo/api/abuse';
import { clearError } from 'amo/reducers/errors';
import collectionAbuseReportsReducer, {
abortCollectionAbuseReport,
loadCollectionAbuseReport,
sendCollectionAbuseReport,
} from 'amo/reducers/collectionAbuseReports';
import apiReducer from 'amo/reducers/api';
import collectionAbuseReportsSaga from 'amo/sagas/collectionAbuseReports';
import {
createFakeCollectionAbuseReport,
createStubErrorHandler,
dispatchSignInActions,
} from 'tests/unit/helpers';
describe(__filename, () => {
let errorHandler;
let mockApi;
let sagaTester;
beforeEach(() => {
errorHandler = createStubErrorHandler();
mockApi = sinon.mock(api);
const initialState = dispatchSignInActions().state;
sagaTester = new SagaTester({
initialState,
reducers: {
api: apiReducer,
collectionAbuseReports: collectionAbuseReportsReducer,
},
});
sagaTester.start(collectionAbuseReportsSaga);
});
function _sendCollectionAbuseReport(params) {
sagaTester.dispatch(
sendCollectionAbuseReport({
errorHandlerId: errorHandler.id,
collectionId: 999999,
message: 'abuse report body',
...params,
}),
);
}
it('calls the API for abuse', async () => {
const collectionId = 123;
const message = 'this is not a great collection';
const response = createFakeCollectionAbuseReport({ collectionId, message });
mockApi
.expects('reportCollection')
.once()
.returns(Promise.resolve(response));
_sendCollectionAbuseReport({ message, collectionId });
const expectedLoadAction = loadCollectionAbuseReport({
message: response.message,
reporter: response.reporter,
collectionId,
});
const calledAction = await sagaTester.waitFor(expectedLoadAction.type);
mockApi.verify();
expect(calledAction).toEqual(expectedLoadAction);
});
it('calls the API for abuse with a reason', async () => {
const collectionId = 123;
const reason = 'other';
const response = createFakeCollectionAbuseReport({ collectionId, reason });
mockApi
.expects('reportCollection')
.once()
.returns(Promise.resolve(response));
_sendCollectionAbuseReport({ collectionId, reason });
const expectedLoadAction = loadCollectionAbuseReport({ collectionId });
const calledAction = await sagaTester.waitFor(expectedLoadAction.type);
mockApi.verify();
expect(calledAction).toEqual(expectedLoadAction);
});
it('clears the error handler', async () => {
_sendCollectionAbuseReport();
const calledAction = await sagaTester.waitFor(clearError.type);
expect(calledAction).toEqual(errorHandler.createClearingAction());
});
it('dispatches an error', async () => {
const error = new Error('some API error maybe');
mockApi.expects('reportCollection').returns(Promise.reject(error));
_sendCollectionAbuseReport();
const errorAction = errorHandler.createErrorAction(error);
const calledAction = await sagaTester.waitFor(errorAction.type);
expect(calledAction).toEqual(errorAction);
});
it('resets the state when an error occurs', async () => {
const collectionId = 123;
const error = new Error('some API error maybe');
mockApi.expects('reportCollection').returns(Promise.reject(error));
_sendCollectionAbuseReport({ collectionId });
const abortAction = abortCollectionAbuseReport({ collectionId });
const calledAction = await sagaTester.waitFor(abortAction.type);
expect(calledAction).toEqual(abortAction);
});
});

Просмотреть файл

@ -1018,6 +1018,25 @@ export function createFakeUserAbuseReport({
};
}
export function createFakeCollectionAbuseReport({
collectionId,
message = '',
reason = 'other',
reporter = null,
reporterName = null,
reporterEmail = null,
} = {}) {
return {
message,
reason,
reporter,
reporter_name: reporterName,
reporter_email: reporterEmail,
collection: {
id: collectionId,
},
};
}
export const getFakeConfig = getFakeConfigNode;
export const getMockConfig = (overrides = {}) => {