Skip to content

Commit 4be3bb4

Browse files
author
Aaron Cook
authored
feat: DELETE /spaces/:spaceId/address-book/:address (#2549)
Adds a new endpoint for deleting specified addresses from given Spaces: `DELETE` `/spaces/:spaceId/address-book/:address`. The scaffolded `AddressBookItemsRepository['deleteMany']` has been renamed to `deleteByAddress` as deletion is only required for singular addresses, and it has been propagated to the appropriate controller: - Rename `AddressBookItemsRepository['deleteMany']` to `deleteByAddress` - Implement `AddressBookItemsRepository['deleteByAddress']` - Add `DELETE` `/spaces/:spaceId/address-book/:address` to `AddressBooksController` - Add appropriate test coverage
1 parent 0034118 commit 4be3bb4

6 files changed

+298
-10
lines changed

src/domain/spaces/address-books/address-book-items.repository.integration.spec.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,103 @@ describe('AddressBookItemsRepository', () => {
380380
});
381381
});
382382

383+
describe('deleteByAddress', () => {
384+
it('should delete an address book item by address', async () => {
385+
const { spaceId, authPayload } = await createSpaceAsAdmin();
386+
const addressBookItem = addressBookItemBuilder()
387+
.with('space', spaceBuilder().with('id', spaceId).build())
388+
.with('createdBy', authPayload.signer_address as `0x${string}`)
389+
.with('lastUpdatedBy', authPayload.signer_address as `0x${string}`)
390+
.build();
391+
await dbAddressBookItemsRepository.insert(addressBookItem);
392+
393+
await addressBookItemsRepository.deleteByAddress({
394+
authPayload,
395+
spaceId,
396+
address: addressBookItem.address,
397+
});
398+
399+
await expect(
400+
dbAddressBookItemsRepository.findOneBy({
401+
address: addressBookItem.address,
402+
}),
403+
).resolves.toBe(null);
404+
});
405+
406+
it('should not delete the same address from a different Space', async () => {
407+
const { spaceId: spaceId1, authPayload: authPayload1 } =
408+
await createSpaceAsAdmin();
409+
const { spaceId: spaceId2, authPayload: authPayload2 } =
410+
await createSpaceAsAdmin();
411+
const address = getAddress(faker.finance.ethereumAddress());
412+
const addressBookItem1 = addressBookItemBuilder()
413+
.with('address', address)
414+
.with('space', spaceBuilder().with('id', spaceId1).build())
415+
.with('createdBy', authPayload1.signer_address as `0x${string}`)
416+
.with('lastUpdatedBy', authPayload1.signer_address as `0x${string}`)
417+
.build();
418+
const addressBookItem2 = addressBookItemBuilder()
419+
.with('address', address)
420+
.with('space', spaceBuilder().with('id', spaceId2).build())
421+
.with('createdBy', authPayload2.signer_address as `0x${string}`)
422+
.with('lastUpdatedBy', authPayload2.signer_address as `0x${string}`)
423+
.build();
424+
await dbAddressBookItemsRepository.insert(addressBookItem1);
425+
await dbAddressBookItemsRepository.insert(addressBookItem2);
426+
427+
await addressBookItemsRepository.deleteByAddress({
428+
authPayload: authPayload1,
429+
spaceId: spaceId1,
430+
address: addressBookItem1.address,
431+
});
432+
433+
await expect(
434+
dbAddressBookItemsRepository.findOneBy({
435+
address: addressBookItem1.address,
436+
space: { id: spaceId2 },
437+
}),
438+
).resolves.toStrictEqual(
439+
expect.objectContaining({
440+
id: expect.any(Number),
441+
address: addressBookItem1.address,
442+
name: addressBookItem2.name,
443+
chainIds: addressBookItem2.chainIds,
444+
createdBy: addressBookItem2.createdBy,
445+
lastUpdatedBy: addressBookItem2.lastUpdatedBy,
446+
createdAt: expect.any(Date),
447+
updatedAt: expect.any(Date),
448+
}),
449+
);
450+
});
451+
452+
it('should throw a NotFoundException if the space does not exist', async () => {
453+
const { authPayload } = await createUser();
454+
const addressBookItem = addressBookItemBuilder().build();
455+
456+
await expect(
457+
addressBookItemsRepository.deleteByAddress({
458+
authPayload,
459+
spaceId: faker.number.int({ min: 1, max: DB_MAX_SAFE_INTEGER }),
460+
address: addressBookItem.address,
461+
}),
462+
).rejects.toThrow(new NotFoundException('Space not found.'));
463+
});
464+
465+
it('should throw NotFoundException if the user is not an ADMIN', async () => {
466+
const { spaceId } = await createSpaceAsAdmin();
467+
const authPayload = await addMemberToSpaceWithStatus(spaceId, 'ACTIVE');
468+
const addressBookItem = addressBookItemBuilder().build();
469+
470+
await expect(
471+
addressBookItemsRepository.deleteByAddress({
472+
authPayload,
473+
spaceId,
474+
address: addressBookItem.address,
475+
}),
476+
).rejects.toThrow(new NotFoundException('Space not found.'));
477+
});
478+
});
479+
383480
// Utility functions
384481

385482
const createSpaceAsAdmin = async (): Promise<{

src/domain/spaces/address-books/address-book-items.repository.interface.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,14 @@ export interface IAddressBookItemsRepository {
3939
}): Promise<Array<AddressBookDbItem>>;
4040

4141
/**
42-
* Deletes an array of AddressBookItems by their IDs.
42+
* Deletes an {@link AddressBookDbItem} by address.
4343
* @param {AuthPayload} args.authPayload - The authentication payload.
4444
* @param {number} args.spaceId - The ID of the Space.
45-
* @param {Array<AddressBookDbItem>} args.addressBookItemIds - The IDs of the AddressBookItems to delete.
45+
* @param {AddressBookDbItem['address']} args.address - The address of an AddressBookItem to delete.
4646
*/
47-
deleteMany(args: {
47+
deleteByAddress(args: {
4848
authPayload: AuthPayload;
4949
spaceId: Space['id'];
50-
addressBookItemIds: Array<string>;
50+
address: AddressBookDbItem['address'];
5151
}): Promise<void>;
5252
}

src/domain/spaces/address-books/address-book-items.repository.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,21 @@ export class AddressBookItemsRepository implements IAddressBookItemsRepository {
8484
return repository.findBy({ space: { id: space.id } });
8585
}
8686

87-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
88-
public deleteMany(args: {
87+
public async deleteByAddress(args: {
8988
authPayload: AuthPayload;
9089
spaceId: Space['id'];
91-
addressBookItemIds: Array<string>;
90+
address: AddressBookDbItem['address'];
9291
}): Promise<void> {
93-
throw new Error('Method not implemented.');
92+
const space = await this.getSpaceAs({
93+
...args,
94+
memberRoleIn: ['ADMIN'],
95+
});
96+
const repository = await this.db.getRepository(DbAddressBookItem);
97+
98+
await repository.delete({
99+
address: args.address,
100+
space: { id: space.id },
101+
});
94102
}
95103

96104
private async getSpaceAs(args: {

src/routes/spaces/address-books.controller.spec.ts

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type { Server } from 'net';
3131
import request from 'supertest';
3232
import { getAddress } from 'viem';
3333
import { DB_MAX_SAFE_INTEGER } from '@/domain/common/constants';
34+
import type { AuthPayload } from '@/domain/auth/entities/auth-payload.entity';
3435

3536
describe('AddressBooksController', () => {
3637
let app: INestApplication<Server>;
@@ -480,6 +481,157 @@ describe('AddressBooksController', () => {
480481
});
481482
});
482483

484+
describe('DELETE /spaces/:spaceId/address-book/:address', () => {
485+
it('should delete a Space address book item', async () => {
486+
const { spaceId, accessToken } = await createSpace();
487+
const { mockAddress } = await createAddressBookItem({
488+
spaceId,
489+
adminAccessToken: accessToken,
490+
});
491+
492+
await request(app.getHttpServer())
493+
.delete(`/v1/spaces/${spaceId}/address-book/${mockAddress}`)
494+
.set('Cookie', [`access_token=${accessToken}`])
495+
.expect(200);
496+
497+
await request(app.getHttpServer())
498+
.get(`/v1/spaces/${spaceId}/address-book`)
499+
.set('Cookie', [`access_token=${accessToken}`])
500+
.expect(200)
501+
.expect(({ body }) =>
502+
expect(body).toEqual({
503+
spaceId: spaceId.toString(),
504+
data: [],
505+
}),
506+
);
507+
});
508+
509+
it('should not delete the same address from a different Space', async () => {
510+
const { spaceId: spaceId1, accessToken: accessToken1 } =
511+
await createSpace();
512+
const { spaceId: spaceId2, accessToken: accessToken2 } =
513+
await createSpace();
514+
const authPayload2 = jwtService.decode(accessToken2) as AuthPayload;
515+
const { mockAddress } = await createAddressBookItem({
516+
spaceId: spaceId1,
517+
adminAccessToken: accessToken1,
518+
});
519+
const { mockName, mockChainIds } = await createAddressBookItem({
520+
spaceId: spaceId2,
521+
adminAccessToken: accessToken2,
522+
address: mockAddress,
523+
});
524+
525+
await request(app.getHttpServer())
526+
.delete(`/v1/spaces/${spaceId1}/address-book/${mockAddress}`)
527+
.set('Cookie', [`access_token=${accessToken1}`])
528+
.expect(200);
529+
530+
await request(app.getHttpServer())
531+
.get(`/v1/spaces/${spaceId2}/address-book`)
532+
.set('Cookie', [`access_token=${accessToken2}`])
533+
.expect(200)
534+
.expect(({ body }) =>
535+
expect(body).toEqual({
536+
spaceId: spaceId2.toString(),
537+
data: [
538+
{
539+
address: mockAddress,
540+
name: mockName,
541+
chainIds: mockChainIds,
542+
createdBy: authPayload2.signer_address,
543+
lastUpdatedBy: authPayload2.signer_address,
544+
},
545+
],
546+
}),
547+
);
548+
});
549+
550+
it('should return a 404 if a space ID does not exist', async () => {
551+
const { accessToken } = await createSpace();
552+
const nonExistingSpaceId = faker.number.int({
553+
min: 69420,
554+
max: DB_MAX_SAFE_INTEGER,
555+
});
556+
const address = getAddress(faker.finance.ethereumAddress());
557+
558+
await request(app.getHttpServer())
559+
.delete(`/v1/spaces/${nonExistingSpaceId}/address-book/${address}`)
560+
.set('Cookie', [`access_token=${accessToken}`])
561+
.expect(404)
562+
.expect({
563+
statusCode: 404,
564+
message: 'Space not found.',
565+
error: 'Not Found',
566+
});
567+
});
568+
569+
it('should return a 404 if the user does not exist', async () => {
570+
const { spaceId } = await createSpace();
571+
const authPayloadDto = authPayloadDtoBuilder().build();
572+
const accessToken = jwtService.sign(authPayloadDto);
573+
const address = getAddress(faker.finance.ethereumAddress());
574+
575+
await request(app.getHttpServer())
576+
.delete(`/v1/spaces/${spaceId}/address-book/${address}`)
577+
.set('Cookie', [`access_token=${accessToken}`])
578+
.expect(404)
579+
.expect({
580+
statusCode: 404,
581+
message: 'User not found.',
582+
error: 'Not Found',
583+
});
584+
});
585+
586+
it('should return a 404 if the member is not an ADMIN', async () => {
587+
const { spaceId, accessToken } = await createSpace();
588+
const { memberAccessToken } = await inviteMember({
589+
spaceId,
590+
adminAccessToken: accessToken,
591+
});
592+
const address = getAddress(faker.finance.ethereumAddress());
593+
594+
await request(app.getHttpServer())
595+
.delete(`/v1/spaces/${spaceId}/address-book/${address}`)
596+
.set('Cookie', [`access_token=${memberAccessToken}`])
597+
.expect(404)
598+
.expect({
599+
statusCode: 404,
600+
message: 'Space not found.',
601+
error: 'Not Found',
602+
});
603+
});
604+
605+
it('should return a 403 if the AuthPayload is empty', async () => {
606+
const { spaceId } = await createSpace();
607+
const address = getAddress(faker.finance.ethereumAddress());
608+
609+
await request(app.getHttpServer())
610+
.delete(`/v1/spaces/${spaceId}/address-book/${address}`)
611+
.set('Cookie', [`access_token=`])
612+
.expect(403)
613+
.expect({
614+
statusCode: 403,
615+
message: 'Forbidden resource',
616+
error: 'Forbidden',
617+
});
618+
});
619+
620+
it('should return a 403 if not authenticated', async () => {
621+
const { spaceId } = await createSpace();
622+
const address = getAddress(faker.finance.ethereumAddress());
623+
624+
await request(app.getHttpServer())
625+
.delete(`/v1/spaces/${spaceId}/address-book/${address}`)
626+
.expect(403)
627+
.expect({
628+
statusCode: 403,
629+
message: 'Forbidden resource',
630+
error: 'Forbidden',
631+
});
632+
});
633+
});
634+
483635
// Utility functions
484636

485637
const createSpace = async (): Promise<{
@@ -524,12 +676,14 @@ describe('AddressBooksController', () => {
524676
const createAddressBookItem = async (args: {
525677
spaceId: string;
526678
adminAccessToken: string;
679+
address?: `0x${string}`;
527680
}): Promise<{
528681
mockName: string;
529-
mockAddress: string;
682+
mockAddress: `0x${string}`;
530683
mockChainIds: Array<string>;
531684
}> => {
532-
const mockAddress = getAddress(faker.finance.ethereumAddress());
685+
const mockAddress =
686+
args?.address ?? getAddress(faker.finance.ethereumAddress());
533687
const mockName = nameBuilder();
534688
const mockChainIds = faker.helpers.multiple(() => faker.string.numeric(), {
535689
count: { min: 1, max: 5 },

src/routes/spaces/address-books.controller.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SpaceAddressBookDto } from '@/routes/spaces/entities/space-address-book
66
import {
77
Body,
88
Controller,
9+
Delete,
910
Get,
1011
Inject,
1112
Param,
@@ -26,6 +27,7 @@ import {
2627
UpsertAddressBookItemsDto,
2728
UpsertAddressBookItemsSchema,
2829
} from '@/routes/spaces/entities/upsert-address-book-items.dto.entity';
30+
import { AddressSchema } from '@/validation/entities/schemas/address.schema';
2931

3032
@ApiTags('spaces')
3133
@Controller({ path: 'spaces', version: '1' })
@@ -72,4 +74,23 @@ export class AddressBooksController {
7274
): Promise<SpaceAddressBookDto> {
7375
return this.service.upsertMany(authPayload, spaceId, addressBookItems);
7476
}
77+
78+
@ApiOkResponse({
79+
description: 'Address book item deleted',
80+
})
81+
@ApiNotFoundResponse({ description: 'User, member or Space not found' })
82+
@ApiForbiddenResponse({
83+
description: 'Signer address not present or not authorized',
84+
})
85+
@Delete('/:spaceId/address-book/:address')
86+
@UseGuards(AuthGuard)
87+
public async deleteByAddress(
88+
@Auth() authPayload: AuthPayload,
89+
@Param('spaceId', ParseIntPipe, new ValidationPipe(RowSchema.shape.id))
90+
spaceId: number,
91+
@Param('address', new ValidationPipe(AddressSchema))
92+
address: `0x${string}`,
93+
): Promise<void> {
94+
return this.service.deleteByAddress({ authPayload, spaceId, address });
95+
}
7596
}

src/routes/spaces/address-books.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ export class AddressBooksService {
4848
return this.mapAddressBookItems(spaceId, updatedItems);
4949
}
5050

51+
public async deleteByAddress(args: {
52+
authPayload: AuthPayload;
53+
spaceId: Space['id'];
54+
address: AddressBookDbItem['address'];
55+
}): Promise<void> {
56+
await this.repository.deleteByAddress(args);
57+
}
58+
5159
private mapAddressBookItems(
5260
spaceId: Space['id'],
5361
items: Array<AddressBookDbItem>,

0 commit comments

Comments
 (0)