Skip to content

Commit d8112b4

Browse files
committed
feat: add a delete notifications endpoint
The endpoint allows the deletion of multiple safe push notification subscriptions at once similar to the register endpoint where one can provide multiple safes to subscribe to.
1 parent ac19d97 commit d8112b4

File tree

6 files changed

+224
-1
lines changed

6 files changed

+224
-1
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { deleteSubscriptionsDtoBuilder } from '@/routes/notifications/v2/entities/__tests__/delete-subscriptions.dto.builder';
2+
import { DeleteSubscriptionsDtoSchema } from '@/domain/notifications/v2/entities/delete-subscriptions.dto.entity';
3+
import { faker } from '@faker-js/faker';
4+
import { getAddress } from 'viem';
5+
6+
describe('DeleteSubscriptionsDtoSchema', () => {
7+
it('should validate a valid DeleteSubscriptionsDto', () => {
8+
const dto = deleteSubscriptionsDtoBuilder().build();
9+
10+
const result = DeleteSubscriptionsDtoSchema.safeParse(dto);
11+
12+
expect(result.success).toBe(true);
13+
});
14+
15+
it('should require safes', () => {
16+
const dto = deleteSubscriptionsDtoBuilder().build();
17+
// @ts-expect-error delete safes
18+
delete dto.safes;
19+
20+
const result = DeleteSubscriptionsDtoSchema.safeParse(dto);
21+
22+
expect(!result.success && result.error.issues).toStrictEqual([
23+
{
24+
code: 'invalid_type',
25+
expected: 'array',
26+
message: 'Required',
27+
path: ['safes'],
28+
received: 'undefined',
29+
},
30+
]);
31+
});
32+
33+
it.each([
34+
['chainId' as const, 'string'],
35+
['address' as const, 'string'],
36+
])('should require safes[number].%s', (key, expected) => {
37+
const dto = deleteSubscriptionsDtoBuilder()
38+
.with('safes', [
39+
{
40+
chainId: faker.string.numeric(),
41+
address: getAddress(faker.finance.ethereumAddress()),
42+
},
43+
])
44+
.build();
45+
// @ts-expect-error test missing property
46+
delete dto.safes[0][key];
47+
48+
const result = DeleteSubscriptionsDtoSchema.safeParse(dto);
49+
50+
expect(!result.success && result.error.issues).toStrictEqual([
51+
{
52+
code: 'invalid_type',
53+
expected,
54+
message: 'Required',
55+
path: ['safes', 0, key],
56+
received: 'undefined',
57+
},
58+
]);
59+
});
60+
61+
it('should checksum safes[number].address', () => {
62+
const nonChecksum = faker.finance.ethereumAddress().toLowerCase();
63+
const dto = deleteSubscriptionsDtoBuilder()
64+
.with('safes', [
65+
{
66+
chainId: faker.string.numeric(),
67+
address: nonChecksum as `0x${string}`,
68+
},
69+
])
70+
.build();
71+
72+
const result = DeleteSubscriptionsDtoSchema.safeParse(dto);
73+
74+
expect(result.success && result.data.safes[0].address).toBe(
75+
getAddress(nonChecksum),
76+
);
77+
});
78+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { AddressSchema } from '@/validation/entities/schemas/address.schema';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
import { z } from 'zod';
4+
5+
export const DeleteSubscriptionsDtoSafesSchema = z.object({
6+
chainId: z.string(),
7+
address: AddressSchema,
8+
});
9+
10+
export const DeleteSubscriptionsDtoSchema = z.object({
11+
safes: z.array(DeleteSubscriptionsDtoSafesSchema),
12+
});
13+
14+
export class DeleteSubscriptionsSafesDto
15+
implements z.infer<typeof DeleteSubscriptionsDtoSafesSchema>
16+
{
17+
@ApiProperty()
18+
chainId!: string;
19+
20+
@ApiProperty()
21+
address!: `0x${string}`;
22+
}
23+
24+
export class DeleteSubscriptionsDto
25+
implements z.infer<typeof DeleteSubscriptionsDtoSchema>
26+
{
27+
@ApiProperty({ type: [DeleteSubscriptionsSafesDto] })
28+
safes!: Array<DeleteSubscriptionsSafesDto>;
29+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { faker } from '@faker-js/faker';
2+
import type { IBuilder } from '@/__tests__/builder';
3+
import { Builder } from '@/__tests__/builder';
4+
import { getAddress } from 'viem';
5+
import type { DeleteSubscriptionsDto } from '@/domain/notifications/v2/entities/delete-subscriptions.dto.entity';
6+
7+
export function deleteSubscriptionsDtoBuilder(): IBuilder<DeleteSubscriptionsDto> {
8+
return new Builder<DeleteSubscriptionsDto>().with(
9+
'safes',
10+
Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({
11+
chainId: faker.string.numeric(),
12+
address: getAddress(faker.finance.ethereumAddress()),
13+
})),
14+
);
15+
}

src/routes/notifications/v2/notifications.controller.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder';
2121
import { TestLoggingModule } from '@/logging/__tests__/test.logging.module';
2222
import { RequestScopedLoggingModule } from '@/logging/logging.module';
2323
import { upsertSubscriptionsDtoBuilder } from '@/routes/notifications/v2/entities/__tests__/upsert-subscriptions.dto.builder';
24+
import { deleteSubscriptionsDtoBuilder } from '@/routes/notifications/v2/entities/__tests__/delete-subscriptions.dto.builder';
2425
import type { Chain } from '@/routes/chains/entities/chain.entity';
2526
import { faker } from '@faker-js/faker';
2627
import type { INestApplication } from '@nestjs/common';
@@ -1001,6 +1002,63 @@ describe('Notifications Controller V2 (Unit)', () => {
10011002
});
10021003
});
10031004

1005+
describe('DELETE /v2/notifications/devices/:deviceUuid/safes', () => {
1006+
it('should delete the subscriptions for the provided Safes', async () => {
1007+
const deviceUuid = faker.string.uuid();
1008+
const dto = deleteSubscriptionsDtoBuilder().build();
1009+
1010+
await request(app.getHttpServer())
1011+
.delete(`/v2/notifications/devices/${deviceUuid}/safes`)
1012+
.send(dto)
1013+
.expect(200);
1014+
1015+
expect(notificationsRepository.deleteSubscription).toHaveBeenCalledTimes(
1016+
dto.safes.length,
1017+
);
1018+
for (const [index, safe] of dto.safes.entries()) {
1019+
expect(notificationsRepository.deleteSubscription).toHaveBeenNthCalledWith(
1020+
index + 1,
1021+
{
1022+
deviceUuid,
1023+
chainId: safe.chainId,
1024+
safeAddress: safe.address,
1025+
},
1026+
);
1027+
}
1028+
});
1029+
1030+
it('should return 422 if the deviceUuid is invalid', async () => {
1031+
const invalidDeviceUuid = faker.string.alphanumeric();
1032+
const dto = deleteSubscriptionsDtoBuilder().build();
1033+
1034+
await request(app.getHttpServer())
1035+
.delete(`/v2/notifications/devices/${invalidDeviceUuid}/safes`)
1036+
.send(dto)
1037+
.expect({
1038+
statusCode: 422,
1039+
validation: 'uuid',
1040+
code: 'invalid_string',
1041+
message: 'Invalid UUID',
1042+
path: [],
1043+
});
1044+
});
1045+
1046+
it('should forward datasource errors', async () => {
1047+
const deviceUuid = faker.string.uuid();
1048+
const dto = deleteSubscriptionsDtoBuilder().build();
1049+
const error = new NotFoundException();
1050+
notificationsRepository.deleteSubscription.mockRejectedValue(error);
1051+
1052+
await request(app.getHttpServer())
1053+
.delete(`/v2/notifications/devices/${deviceUuid}/safes`)
1054+
.send(dto)
1055+
.expect({
1056+
message: 'Not Found',
1057+
statusCode: 404,
1058+
});
1059+
});
1060+
});
1061+
10041062
describe('DELETE /v2/chains/:chainId/notifications/devices/:deviceUuid', () => {
10051063
it('should delete the device', async () => {
10061064
const chainId = faker.string.numeric();

src/routes/notifications/v2/notifications.controller.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity';
22
import { UpsertSubscriptionsDto } from '@/routes/notifications/v2/entities/upsert-subscriptions.dto.entity';
33
import { UpsertSubscriptionsDtoSchema } from '@/domain/notifications/v2/entities/upsert-subscriptions.dto.entity';
44
import { NotificationsServiceV2 } from '@/routes/notifications/v2/notifications.service';
5+
import {
6+
DeleteSubscriptionsDto,
7+
DeleteSubscriptionsDtoSchema,
8+
} from '@/domain/notifications/v2/entities/delete-subscriptions.dto.entity';
59
import { Auth } from '@/routes/auth/decorators/auth.decorator';
610
import { AuthGuard } from '@/routes/auth/guards/auth.guard';
711
import { AddressSchema } from '@/validation/entities/schemas/address.schema';
@@ -17,7 +21,7 @@ import {
1721
Post,
1822
UseGuards,
1923
} from '@nestjs/common';
20-
import { ApiTags } from '@nestjs/swagger';
24+
import { ApiTags, ApiBody, ApiOperation, ApiParam } from '@nestjs/swagger';
2125
import { UUID } from 'crypto';
2226
import { OptionalAuthGuard } from '@/routes/auth/guards/optional-auth.guard';
2327
import { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db';
@@ -73,6 +77,32 @@ export class NotificationsControllerV2 {
7377
});
7478
}
7579

80+
@Delete('notifications/devices/:deviceUuid/safes')
81+
@ApiOperation({
82+
summary: 'Delete notification subscriptions for multiple safes',
83+
description:
84+
'Deletes notification subscriptions for a list of safes associated with the specified device UUID',
85+
})
86+
@ApiParam({
87+
name: 'deviceUuid',
88+
description: 'UUID of the device to delete subscriptions for',
89+
type: 'string',
90+
})
91+
@ApiBody({ type: DeleteSubscriptionsDto })
92+
deleteSubscriptions(
93+
@Param('deviceUuid', new ValidationPipe(UuidSchema)) deviceUuid: UUID,
94+
@Body(new ValidationPipe(DeleteSubscriptionsDtoSchema))
95+
deleteSubscriptionsDto: DeleteSubscriptionsDto,
96+
): Promise<void> {
97+
return this.notificationsService.deleteSubscriptions({
98+
deviceUuid,
99+
safes: deleteSubscriptionsDto.safes.map((safe) => ({
100+
chainId: safe.chainId,
101+
safeAddress: safe.address,
102+
})),
103+
});
104+
}
105+
76106
@Delete('chains/:chainId/notifications/devices/:deviceUuid')
77107
deleteDevice(
78108
@Param('chainId', new ValidationPipe(NumericStringSchema)) _chainId: string,

src/routes/notifications/v2/notifications.service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ export class NotificationsServiceV2 {
3838
await this.notificationsRepository.deleteSubscription(args);
3939
}
4040

41+
async deleteSubscriptions(args: {
42+
deviceUuid: UUID;
43+
safes: Array<{ chainId: string; safeAddress: `0x${string}` }>;
44+
}): Promise<void> {
45+
for (const safe of args.safes) {
46+
await this.notificationsRepository.deleteSubscription({
47+
deviceUuid: args.deviceUuid,
48+
chainId: safe.chainId,
49+
safeAddress: safe.safeAddress,
50+
});
51+
}
52+
}
53+
4154
async deleteDevice(deviceUuid: UUID): Promise<void> {
4255
await this.notificationsRepository.deleteDevice(deviceUuid);
4356
}

0 commit comments

Comments
 (0)