Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/social-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `block`, `fetchBlockedProfiles`, and `fetchBlockedContent` methods to `SocialService` (and the matching `SocialService:block`, `SocialService:fetchBlockedProfiles`, and `SocialService:fetchBlockedContent` messenger actions) for the social-api moderation block endpoints ([#10656](https://github.com/MetaMask/core/pull/10656))
- `block` calls `PUT /moderation/block` with exactly one of `profileId`, `commentId`, or `replyId`, plus an optional `reason`. The endpoint returns 204.
- `fetchBlockedProfiles` calls `GET /moderation/blocks/profiles` and returns traders the caller has blocked, most recently blocked first. Pass `cursor` from the previous page; `cursor` is `null` on the last page.
- `fetchBlockedContent` calls `GET /moderation/blocks/content` and returns comments and replies the caller has blocked, most recently blocked first, with the same cursor pagination.

## [3.5.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,57 @@ export type SocialServiceRefreshNotificationPreferencesCacheAction = {
handler: SocialService['refreshNotificationPreferencesCache'];
};

/**
* Blocks a trader, swap comment, or reply for the current user.
*
* Calls `PUT ${baseUrl}/moderation/block`. Provide exactly one of
* `profileId`, `commentId`, or `replyId`. Blocking the same target again
* replaces `reason` when one is given. The caller is identified server-side
* from the JWT sub claim carried in the Authorization header.
*
* @param options - Options bag. Exactly one target, plus an optional reason.
* @param options.profileId - Profile id (UUID) of the trader to block.
* @param options.commentId - Swap comment to block.
* @param options.replyId - Comment reply to block.
* @param options.reason - Free-form reason stored on the block.
*/
export type SocialServiceBlockAction = {
type: `SocialService:block`;
handler: SocialService['block'];
};

/**
* Fetches traders the current user has blocked.
*
* Calls `GET ${baseUrl}/moderation/blocks/profiles`. Results are most
* recently blocked first, 250 per page. The caller is identified
* server-side from the JWT sub claim carried in the Authorization header.
*
* @param options - Options bag.
* @param options.cursor - Cursor from the previous page. Omit for the first page.
* @returns The blocked profiles page.
*/
export type SocialServiceFetchBlockedProfilesAction = {
type: `SocialService:fetchBlockedProfiles`;
handler: SocialService['fetchBlockedProfiles'];
};

/**
* Fetches comments and replies the current user has blocked.
*
* Calls `GET ${baseUrl}/moderation/blocks/content`. Results are most
* recently blocked first, 250 per page. The caller is identified
* server-side from the JWT sub claim carried in the Authorization header.
*
* @param options - Options bag.
* @param options.cursor - Cursor from the previous page. Omit for the first page.
* @returns The blocked content page.
*/
export type SocialServiceFetchBlockedContentAction = {
type: `SocialService:fetchBlockedContent`;
handler: SocialService['fetchBlockedContent'];
};

/**
* Union of all SocialService action types.
*/
Expand All @@ -334,4 +385,7 @@ export type SocialServiceMethodActions =
| SocialServiceUnfollowAction
| SocialServiceOptOutOfLeaderboardAction
| SocialServiceOptInToLeaderboardAction
| SocialServiceRefreshNotificationPreferencesCacheAction;
| SocialServiceRefreshNotificationPreferencesCacheAction
| SocialServiceBlockAction
| SocialServiceFetchBlockedProfilesAction
| SocialServiceFetchBlockedContentAction;
312 changes: 312 additions & 0 deletions packages/social-controllers/src/SocialService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2012,4 +2012,316 @@ describe('SocialService', () => {
expect(result).toBeUndefined();
});
});

describe('block', () => {
it('sends PUT with a profile target and reason to /moderation/block', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 204 });

const service = createService();
const result = await service.block({
profileId: mockProfileSummary.profileId,
reason: 'spam',
});

expect(result).toBeUndefined();
expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/moderation/block`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${MOCK_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
profileId: mockProfileSummary.profileId,
reason: 'spam',
}),
});
});

it('sends a comment target without a reason', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 204 });

const service = createService();
await service.block({ commentId: 'comment-1' });

expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/moderation/block`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${MOCK_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ commentId: 'comment-1' }),
});
});

it('sends a reply target', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 204 });

const service = createService();
await service.block({ replyId: 'reply-1', reason: 'harassment' });

expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/moderation/block`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${MOCK_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ replyId: 'reply-1', reason: 'harassment' }),
});
});

it('throws HttpError on non-ok response', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 400 });

const service = createService();

await expect(
service.block({ profileId: mockProfileSummary.profileId }),
).rejects.toThrow(`${SocialServiceErrorMessage.BLOCK_FAILED}: 400`);
});

it('always sends the request on repeated calls (staleTime: 0)', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 204 });

const service = createService();
await service.block({ commentId: 'comment-1' });
await service.block({ commentId: 'comment-1' });

expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('is callable via messenger action', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 204 });

const messenger = createMessenger();
// eslint-disable-next-line no-new
new SocialService({ messenger, baseUrl: BASE_URL });

const result = await messenger.call('SocialService:block', {
replyId: 'reply-1',
});

expect(result).toBeUndefined();
});
});

describe('fetchBlockedProfiles', () => {
const mockBlockedProfilesResponse = {
items: [
{
blockedAt: 1700000000,
reason: 'spam',
profile: mockProfileSummary,
},
],
cursor: '1710000000000',
};

it('fetches the first page from /moderation/blocks/profiles', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockBlockedProfilesResponse),
});

const service = createService();
const result = await service.fetchBlockedProfiles();

expect(result).toStrictEqual(mockBlockedProfilesResponse);
expect(mockFetch).toHaveBeenCalledWith(
`${V1_URL}/moderation/blocks/profiles`,
{ headers: { Authorization: `Bearer ${MOCK_TOKEN}` } },
);
});

it('sends the cursor query param for the next page', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({ ...mockBlockedProfilesResponse, cursor: null }),
});

const service = createService();
const result = await service.fetchBlockedProfiles({
cursor: '1710000000000',
});

expect(result.cursor).toBeNull();
expect(mockFetch).toHaveBeenCalledWith(
`${V1_URL}/moderation/blocks/profiles?cursor=1710000000000`,
{ headers: { Authorization: `Bearer ${MOCK_TOKEN}` } },
);
});

it('accepts a profile that omits imageUrl', async () => {
const { imageUrl: _imageUrl, ...profileWithoutImage } =
mockProfileSummary;
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
items: [
{
blockedAt: 1700000000,
reason: null,
profile: profileWithoutImage,
},
],
cursor: null,
}),
});

const service = createService();
const result = await service.fetchBlockedProfiles();

expect(result.items[0]?.profile).toStrictEqual(profileWithoutImage);
});

it('throws HttpError on non-ok response', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 401 });

const service = createService();

await expect(service.fetchBlockedProfiles()).rejects.toThrow(
`${SocialServiceErrorMessage.FETCH_BLOCKED_PROFILES_FAILED}: 401`,
);
});

it('throws when response schema is invalid', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ items: 'not-an-array', cursor: null }),
});

const service = createService();

await expect(service.fetchBlockedProfiles()).rejects.toThrow(
SocialServiceErrorMessage.FETCH_BLOCKED_PROFILES_INVALID_RESPONSE,
);
});

it('is callable via messenger action', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockBlockedProfilesResponse),
});

const messenger = createMessenger();
// eslint-disable-next-line no-new
new SocialService({ messenger, baseUrl: BASE_URL });

const result = await messenger.call(
'SocialService:fetchBlockedProfiles',
{ cursor: '1710000000000' },
);

expect(result).toStrictEqual(mockBlockedProfilesResponse);
});
});

describe('fetchBlockedContent', () => {
const mockBlockedContentResponse = {
items: [
{
blockedAt: 1700000000,
reason: 'misleading',
type: 'comment',
id: 'comment-1',
text: 'this is a post',
createdAt: 1690000000,
author: mockProfileSummary,
},
{
blockedAt: 1690000000,
reason: null,
type: 'reply',
id: 'reply-1',
text: 'this is a reply',
createdAt: 1680000000,
author: null,
},
],
cursor: null,
};

it('fetches the first page from /moderation/blocks/content', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockBlockedContentResponse),
});

const service = createService();
const result = await service.fetchBlockedContent();

expect(result).toStrictEqual(mockBlockedContentResponse);
expect(mockFetch).toHaveBeenCalledWith(
`${V1_URL}/moderation/blocks/content`,
{ headers: { Authorization: `Bearer ${MOCK_TOKEN}` } },
);
});

it('sends the cursor query param for the next page', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockBlockedContentResponse),
});

const service = createService();
await service.fetchBlockedContent({ cursor: '1710000000000' });

expect(mockFetch).toHaveBeenCalledWith(
`${V1_URL}/moderation/blocks/content?cursor=1710000000000`,
{ headers: { Authorization: `Bearer ${MOCK_TOKEN}` } },
);
});

it('throws HttpError on non-ok response', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500 });

const service = createService();

await expect(service.fetchBlockedContent()).rejects.toThrow(
`${SocialServiceErrorMessage.FETCH_BLOCKED_CONTENT_FAILED}: 500`,
);
});

it('throws when response schema is invalid', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
items: [{ ...mockBlockedContentResponse.items[0], type: 'post' }],
cursor: null,
}),
});

const service = createService();

await expect(service.fetchBlockedContent()).rejects.toThrow(
SocialServiceErrorMessage.FETCH_BLOCKED_CONTENT_INVALID_RESPONSE,
);
});

it('is callable via messenger action', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockBlockedContentResponse),
});

const messenger = createMessenger();
// eslint-disable-next-line no-new
new SocialService({ messenger, baseUrl: BASE_URL });

const result = await messenger.call('SocialService:fetchBlockedContent');

expect(result).toStrictEqual(mockBlockedContentResponse);
});
});
});
Loading
Loading