Skip to content

Commit

Permalink
feat(NOTIFY-1192): add DELETE endpoint support (#4758)
Browse files Browse the repository at this point in the history
## Explanation

This PR adds profile-sync SDK and `UserStorageController` support for
the DELETE endpoint.
This exposes new methods that permit deleting all user entries for a
specific feature.

## References

https://consensyssoftware.atlassian.net/browse/NOTIFY-1192

## Changelog

### `@metamask/profile-sync-controller`

- **ADDED**: `UserStorageController` and profile-sync SDK support for
the `DELETE` all feature entries endpoint


## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [x] I've highlighted breaking changes using the "BREAKING" category
above as appropriate
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
  • Loading branch information
mathieuartu authored Oct 4, 2024
1 parent 3b7b88c commit 9c8beb0
Show file tree
Hide file tree
Showing 9 changed files with 409 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
mockEndpointGetUserStorage,
mockEndpointGetUserStorageAllFeatureEntries,
mockEndpointUpsertUserStorage,
mockEndpointDeleteUserStorageAllFeatureEntries,
} from './__fixtures__/mockServices';
import {
MOCK_STORAGE_DATA,
Expand Down Expand Up @@ -308,6 +309,192 @@ describe('user-storage/user-storage-controller - performSetStorage() tests', ()
});
});

describe('user-storage/user-storage-controller - performBatchSetStorage() tests', () => {
const arrangeMocks = (mockResponseStatus?: number) => {
return {
messengerMocks: mockUserStorageMessenger(),
mockAPI: mockEndpointBatchUpsertUserStorage(
'notifications',
mockResponseStatus ? { status: mockResponseStatus } : undefined,
),
};
};

it('batch saves to user storage', async () => {
const { messengerMocks, mockAPI } = arrangeMocks();
const controller = new UserStorageController({
messenger: messengerMocks.messenger,
getMetaMetricsState: () => true,
});

await controller.performBatchSetStorage('notifications', [
['notifications.notification_settings', 'new data'],
]);
expect(mockAPI.isDone()).toBe(true);
});

it('rejects if UserStorage is not enabled', async () => {
const { messengerMocks } = arrangeMocks();
const controller = new UserStorageController({
messenger: messengerMocks.messenger,
getMetaMetricsState: () => true,
state: {
isProfileSyncingEnabled: false,
isProfileSyncingUpdateLoading: false,
},
});

await expect(
controller.performBatchSetStorage('notifications', [
['notifications.notification_settings', 'new data'],
]),
).rejects.toThrow(expect.any(Error));
});

it.each([
[
'fails when no bearer token is found (auth errors)',
(messengerMocks: ReturnType<typeof mockUserStorageMessenger>) =>
messengerMocks.mockAuthGetBearerToken.mockRejectedValue(
new Error('MOCK FAILURE'),
),
],
[
'fails when no session identifier is found (auth errors)',
(messengerMocks: ReturnType<typeof mockUserStorageMessenger>) =>
messengerMocks.mockAuthGetSessionProfile.mockRejectedValue(
new Error('MOCK FAILURE'),
),
],
])(
'rejects on auth failure - %s',
async (
_: string,
arrangeFailureCase: (
messengerMocks: ReturnType<typeof mockUserStorageMessenger>,
) => void,
) => {
const { messengerMocks } = arrangeMocks();
arrangeFailureCase(messengerMocks);
const controller = new UserStorageController({
messenger: messengerMocks.messenger,
getMetaMetricsState: () => true,
});

await expect(
controller.performBatchSetStorage('notifications', [
['notifications.notification_settings', 'new data'],
]),
).rejects.toThrow(expect.any(Error));
},
);

it('rejects if api call fails', async () => {
const { messengerMocks, mockAPI } = arrangeMocks(500);
const controller = new UserStorageController({
messenger: messengerMocks.messenger,
getMetaMetricsState: () => true,
});

await expect(
controller.performBatchSetStorage('notifications', [
['notifications.notification_settings', 'new data'],
]),
).rejects.toThrow(expect.any(Error));
mockAPI.done();
});
});

describe('user-storage/user-storage-controller - performDeleteStorageAllFeatureEntries() tests', () => {
const arrangeMocks = async (mockResponseStatus?: number) => {
return {
messengerMocks: mockUserStorageMessenger(),
mockAPI: mockEndpointDeleteUserStorageAllFeatureEntries(
'notifications',
mockResponseStatus ? { status: mockResponseStatus } : undefined,
),
};
};

it('deletes all user storage entries for a feature', async () => {
const { messengerMocks, mockAPI } = await arrangeMocks();
const controller = new UserStorageController({
messenger: messengerMocks.messenger,
getMetaMetricsState: () => true,
});

await controller.performDeleteStorageAllFeatureEntries('notifications');
mockAPI.done();

expect(mockAPI.isDone()).toBe(true);
});

it('rejects if UserStorage is not enabled', async () => {
const { messengerMocks } = await arrangeMocks();
const controller = new UserStorageController({
messenger: messengerMocks.messenger,
getMetaMetricsState: () => true,
state: {
isProfileSyncingEnabled: false,
isProfileSyncingUpdateLoading: false,
},
});

await expect(
controller.performDeleteStorageAllFeatureEntries('notifications'),
).rejects.toThrow(expect.any(Error));
});

it.each([
[
'fails when no bearer token is found (auth errors)',
(messengerMocks: ReturnType<typeof mockUserStorageMessenger>) =>
messengerMocks.mockAuthGetBearerToken.mockRejectedValue(
new Error('MOCK FAILURE'),
),
],
[
'fails when no session identifier is found (auth errors)',
(messengerMocks: ReturnType<typeof mockUserStorageMessenger>) =>
messengerMocks.mockAuthGetSessionProfile.mockRejectedValue(
new Error('MOCK FAILURE'),
),
],
])(
'rejects on auth failure - %s',
async (
_: string,
arrangeFailureCase: (
messengerMocks: ReturnType<typeof mockUserStorageMessenger>,
) => void,
) => {
const { messengerMocks } = await arrangeMocks();
arrangeFailureCase(messengerMocks);
const controller = new UserStorageController({
messenger: messengerMocks.messenger,
getMetaMetricsState: () => true,
});

await expect(
controller.performDeleteStorageAllFeatureEntries('notifications'),
).rejects.toThrow(expect.any(Error));
},
);

it('rejects if api call fails', async () => {
const { messengerMocks, mockAPI } = await arrangeMocks(500);
const controller = new UserStorageController({
messenger: messengerMocks.messenger,
getMetaMetricsState: () => true,
});

await expect(
controller.performDeleteStorageAllFeatureEntries('notifications'),
).rejects.toThrow(expect.any(Error));
mockAPI.done();
});
});

describe('user-storage/user-storage-controller - getStorageKey() tests', () => {
const arrangeMocks = async () => {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
import { startNetworkSyncing } from './network-syncing/controller-integration';
import {
batchUpsertUserStorage,
deleteUserStorageAllFeatureEntries,
getUserStorage,
getUserStorageAllFeatureEntries,
upsertUserStorage,
Expand Down Expand Up @@ -677,6 +678,28 @@ export default class UserStorageController extends BaseController<
});
}

/**
* Allows deletion of all user data entries for a specific feature.
* Developers can extend the entry path through the `schema.ts` file.
*
* @param path - string in the form of `${feature}` that matches schema
* @returns nothing. NOTE that an error is thrown if fails to delete data.
*/
public async performDeleteStorageAllFeatureEntries(
path: UserStoragePathWithFeatureOnly,
): Promise<void> {
this.#assertProfileSyncingEnabled();

const { bearerToken, storageKey } =
await this.#getStorageKeyAndBearerToken();

await deleteUserStorageAllFeatureEntries({
path,
bearerToken,
storageKey,
});
}

/**
* Retrieves the storage key, for internal use only!
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {

type MockResponse = {
url: string;
requestMethod: 'GET' | 'POST' | 'PUT';
requestMethod: 'GET' | 'POST' | 'PUT' | 'DELETE';
response: unknown;
};

Expand Down Expand Up @@ -117,3 +117,13 @@ export const getMockUserStorageBatchPutResponse = (
response: null,
} satisfies MockResponse;
};

export const deleteMockUserStorageAllFeatureEntriesResponse = (
path: UserStoragePathWithFeatureOnly = 'notifications',
) => {
return {
url: getMockUserStorageEndpoint(path),
requestMethod: 'DELETE',
response: null,
} satisfies MockResponse;
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getMockUserStoragePutResponse,
getMockUserStorageAllFeatureEntriesResponse,
getMockUserStorageBatchPutResponse,
deleteMockUserStorageAllFeatureEntriesResponse,
} from './mockResponses';

type MockReply = {
Expand Down Expand Up @@ -77,3 +78,17 @@ export const mockEndpointBatchUpsertUserStorage = (
});
return mockEndpoint;
};

export const mockEndpointDeleteUserStorageAllFeatureEntries = (
path: UserStoragePathWithFeatureOnly = 'notifications',
mockReply?: MockReply,
) => {
const mockResponse = deleteMockUserStorageAllFeatureEntriesResponse(path);
const reply = mockReply ?? {
status: 200,
};

const mockEndpoint = nock(mockResponse.url).delete('').reply(reply.status);

return mockEndpoint;
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
mockEndpointUpsertUserStorage,
mockEndpointGetUserStorageAllFeatureEntries,
mockEndpointBatchUpsertUserStorage,
mockEndpointDeleteUserStorageAllFeatureEntries,
} from './__fixtures__/mockServices';
import {
MOCK_STORAGE_DATA,
Expand All @@ -17,6 +18,7 @@ import {
getUserStorage,
getUserStorageAllFeatureEntries,
upsertUserStorage,
deleteUserStorageAllFeatureEntries,
} from './services';

describe('user-storage/services.ts - getUserStorage() tests', () => {
Expand Down Expand Up @@ -241,3 +243,58 @@ describe('user-storage/services.ts - batchUpsertUserStorage() tests', () => {
mockUpsertUserStorage.done();
});
});

describe('user-storage/services.ts - deleteUserStorageAllFeatureEntries() tests', () => {
const actCallDeleteUserStorageAllFeatureEntries = async () => {
return await deleteUserStorageAllFeatureEntries({
bearerToken: 'MOCK_BEARER_TOKEN',
path: 'accounts',
storageKey: MOCK_STORAGE_KEY,
});
};

it('invokes delete endpoint with no errors', async () => {
const mockDeleteUserStorage =
mockEndpointDeleteUserStorageAllFeatureEntries('accounts', undefined);

await actCallDeleteUserStorageAllFeatureEntries();

expect(mockDeleteUserStorage.isDone()).toBe(true);
});

it('throws error if unable to delete user storage', async () => {
const mockDeleteUserStorage =
mockEndpointDeleteUserStorageAllFeatureEntries('accounts', {
status: 500,
});

await expect(actCallDeleteUserStorageAllFeatureEntries()).rejects.toThrow(
expect.any(Error),
);
mockDeleteUserStorage.done();
});

it('throws error if feature not found', async () => {
const mockDeleteUserStorage =
mockEndpointDeleteUserStorageAllFeatureEntries('accounts', {
status: 404,
});

await expect(actCallDeleteUserStorageAllFeatureEntries()).rejects.toThrow(
'user-storage - feature not found',
);
mockDeleteUserStorage.done();
});

it('throws error if unable to get user storage', async () => {
const mockDeleteUserStorage =
mockEndpointDeleteUserStorageAllFeatureEntries('accounts', {
status: 400,
});

await expect(actCallDeleteUserStorageAllFeatureEntries()).rejects.toThrow(
'user-storage - unable to delete data',
);
mockDeleteUserStorage.done();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,32 @@ export async function batchUpsertUserStorage(
throw new Error('user-storage - unable to batch upsert data');
}
}

/**
* User Storage Service - Delete all storage entries for a specific feature.
*
* @param opts - User Storage Options
*/
export async function deleteUserStorageAllFeatureEntries(
opts: UserStorageAllFeatureEntriesOptions,
): Promise<void> {
const { bearerToken, path } = opts;
const url = new URL(`${USER_STORAGE_ENDPOINT}/${path}`);

const userStorageResponse = await fetch(url.toString(), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearerToken}`,
},
});

// Acceptable error - since indicates feature does not exist.
if (userStorageResponse.status === 404) {
throw new Error('user-storage - feature not found');
}

if (userStorageResponse.status !== 200 || !userStorageResponse.ok) {
throw new Error('user-storage - unable to delete data');
}
}
Loading

0 comments on commit 9c8beb0

Please sign in to comment.