From 9254aa77fb563b9ed57c8347268d321a3c7f16e7 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:59:20 -0600 Subject: [PATCH 1/3] feat(bucket): add functionality to create and delete user buckets with corresponding API endpoints and tests --- lib/core/users/usecase.ts | 39 +++++++ lib/server/http/gateway/controller.ts | 65 ++++++++++++ lib/server/http/gateway/index.ts | 2 + tests/lib/core/users/usecase.test.ts | 87 ++++++++++++++++ tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts | 104 +++++++++++++++++++ 5 files changed, 297 insertions(+) diff --git a/lib/core/users/usecase.ts b/lib/core/users/usecase.ts index e15f0b75..973307e4 100644 --- a/lib/core/users/usecase.ts +++ b/lib/core/users/usecase.ts @@ -262,4 +262,43 @@ export class UsersUsecase { await this.usersRepository.removeById(user.id); } + + async findOrCreateBucket( + uuid: User['uuid'], + name: string + ): Promise<{ id: string; name: string }> { + const user = await this.usersRepository.findByUuid(uuid); + + if (!user) { + throw new UserNotFoundError(uuid); + } + + const existing = await this.bucketsRepository.findOne({ userId: uuid, name }); + + if (existing) { + return { id: existing.id, name: existing.name }; + } + + const created = await this.bucketsRepository.create({ + name, + user: user.id, + userId: user.uuid, + status: 'Active', + transfer: 0, + storage: 0, + encryptionKey: '', + }); + + return { id: created.id, name: created.name }; + } + + async deleteBucket(uuid: User['uuid'], bucketId: string): Promise { + const user = await this.usersRepository.findByUuid(uuid); + + if (!user) { + throw new UserNotFoundError(uuid); + } + + await this.bucketsRepository.removeByIdAndUser(bucketId, uuid); + } } diff --git a/lib/server/http/gateway/controller.ts b/lib/server/http/gateway/controller.ts index 3ac32bb5..fa86d21e 100644 --- a/lib/server/http/gateway/controller.ts +++ b/lib/server/http/gateway/controller.ts @@ -13,6 +13,9 @@ type DeleteFilesInBulkResponse = { } | string }; +type CreateBucketBody = { name: string }; +type CreateBucketResponse = { id: string; name: string }; + export class HTTPGatewayController { constructor( private gatewayUsecase: GatewayUsecase, @@ -130,4 +133,66 @@ export class HTTPGatewayController { return res.status(500).send({ message: 'Internal server error' }); } } + + async createUserBucket( + req: Request<{ uuid: string }, {}, Partial, {}>, + res: Response + ) { + const { uuid } = req.params; + const { name } = req.body; + + if (!uuid || typeof name !== 'string' || name.length === 0) { + return res.status(400).send({ message: 'name is required' }); + } + + try { + const bucket = await this.usersUsecase.findOrCreateBucket(uuid, name); + + return res.status(200).send(bucket); + } catch (err) { + if (err instanceof UserNotFoundError) { + return res.status(404).send({ message: err.message }); + } + + this.logger.error( + '[GATEWAY/CREATE_BUCKET] Error creating bucket for user %s: %s. %s', + uuid, + (err as Error).message, + (err as Error).stack || 'NO STACK' + ); + + return res.status(500).send({ message: 'Internal server error' }); + } + } + + async deleteUserBucket( + req: Request<{ uuid: string; id: string }>, + res: Response<{ message: string }> + ) { + const { uuid, id } = req.params; + + if (!uuid || !id) { + return res.status(400).send({ message: 'Missing params' }); + } + + try { + await this.usersUsecase.deleteBucket(uuid, id); + + return res.status(204).end(); + } catch (err) { + if (err instanceof UserNotFoundError) { + return res.status(404).send({ message: err.message }); + } + + this.logger.error( + '[GATEWAY/DELETE_BUCKET] Error deleting bucket %s for user %s: %s. %s', + id, + uuid, + (err as Error).message, + (err as Error).stack || 'NO STACK' + ); + + return res.status(500).send({ message: 'Internal server error' }); + } + } } diff --git a/lib/server/http/gateway/index.ts b/lib/server/http/gateway/index.ts index a2483cd6..3e237efd 100644 --- a/lib/server/http/gateway/index.ts +++ b/lib/server/http/gateway/index.ts @@ -10,6 +10,8 @@ export const createGatewayHTTPRouter = ( router.post('/users', jwtMiddleware, controller.findOrCreateUser.bind(controller)); router.patch('/users/:uuid', jwtMiddleware, controller.updateUserEmail.bind(controller)); router.put('/storage/users/:uuid', jwtMiddleware, controller.changeStorage.bind(controller)); + router.post('/users/:uuid/buckets', jwtMiddleware, controller.createUserBucket.bind(controller)); + router.delete('/users/:uuid/buckets/:id', jwtMiddleware, controller.deleteUserBucket.bind(controller)); router.delete('/storage/files', jwtMiddleware, controller.deleteFilesInBulk.bind(controller)); return router; diff --git a/tests/lib/core/users/usecase.test.ts b/tests/lib/core/users/usecase.test.ts index 64e6f9e7..7c10c772 100644 --- a/tests/lib/core/users/usecase.test.ts +++ b/tests/lib/core/users/usecase.test.ts @@ -416,6 +416,93 @@ describe('Users usecases', () => { }); }); + describe('Finding or creating a bucket', () => { + it('When the user does not exist, then it throws UserNotFoundError', async () => { + const findUser = stub(usersRepository, 'findByUuid').resolves(null); + + try { + await usecase.findOrCreateBucket('unknown-uuid', 'bucket-name'); + expect(true).toBeFalsy(); + } catch (err) { + expect(err).toBeInstanceOf(UserNotFoundError); + } + + expect(findUser.calledOnce).toBeTruthy(); + }); + + it('When no bucket with that name exists, then it creates one', async () => { + const user = fixtures.getUser(); + const name = 'mail-account-id'; + const createdBucket = { id: 'new-bucket-id', name } as any; + + stub(usersRepository, 'findByUuid').resolves(user); + const findBucket = stub(bucketsRepository, 'findOne').resolves(null); + const createBucket = stub(bucketsRepository, 'create').resolves(createdBucket); + + const result = await usecase.findOrCreateBucket(user.uuid, name); + + expect(findBucket.calledOnce).toBeTruthy(); + expect(findBucket.firstCall.args).toStrictEqual([{ userId: user.uuid, name }]); + expect(createBucket.calledOnce).toBeTruthy(); + expect(createBucket.firstCall.args).toStrictEqual([{ + name, + user: user.id, + userId: user.uuid, + status: 'Active', + transfer: 0, + storage: 0, + encryptionKey: '', + }]); + expect(result).toStrictEqual({ id: createdBucket.id, name: createdBucket.name }); + }); + + it('When a bucket with that name already exists, then it returns the existing one without creating', async () => { + const user = fixtures.getUser(); + const name = 'mail-account-id'; + const existingBucket = { id: 'existing-bucket-id', name } as any; + + stub(usersRepository, 'findByUuid').resolves(user); + const findBucket = stub(bucketsRepository, 'findOne').resolves(existingBucket); + const createBucket = stub(bucketsRepository, 'create'); + + const result = await usecase.findOrCreateBucket(user.uuid, name); + + expect(findBucket.calledOnce).toBeTruthy(); + expect(createBucket.called).toBeFalsy(); + expect(result).toStrictEqual({ id: existingBucket.id, name: existingBucket.name }); + }); + }); + + describe('Deleting a bucket', () => { + it('When the user does not exist, then it throws UserNotFoundError', async () => { + const findUser = stub(usersRepository, 'findByUuid').resolves(null); + const removeBucket = stub(bucketsRepository, 'removeByIdAndUser'); + + try { + await usecase.deleteBucket('unknown-uuid', 'bucket-id'); + expect(true).toBeFalsy(); + } catch (err) { + expect(err).toBeInstanceOf(UserNotFoundError); + } + + expect(findUser.calledOnce).toBeTruthy(); + expect(removeBucket.called).toBeFalsy(); + }); + + it('When the user exists, then it removes the bucket by id and user uuid', async () => { + const user = fixtures.getUser(); + const bucketId = 'bucket-id'; + + stub(usersRepository, 'findByUuid').resolves(user); + const removeBucket = stub(bucketsRepository, 'removeByIdAndUser').resolves(); + + await usecase.deleteBucket(user.uuid, bucketId); + + expect(removeBucket.calledOnce).toBeTruthy(); + expect(removeBucket.firstCall.args).toStrictEqual([bucketId, user.uuid]); + }); + }); + describe('Confirming user destruction', () => { it('When confirming a destruction of a user that exists, then it works', async () => { const user = fixtures.getUser(); diff --git a/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts b/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts index e7bf1978..d8f73abe 100644 --- a/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts +++ b/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts @@ -44,6 +44,110 @@ describe('Gateway V2 e2e tests', () => { }) }) + describe('Creating a user bucket', () => { + it('When creating a bucket for a user, then it is persisted with the user uuid and given name', async () => { + const testUser = await createTestUser() + const bucketName = `mail-account-${crypto.randomUUID()}` + + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const response = await testServer + .post(`/v2/gateway/users/${testUser.uuid}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({ name: bucketName }) + + expect(response.status).toBe(200) + expect(response.body.name).toBe(bucketName) + expect(response.body.id).toBeDefined() + + const bucketInDatabase = await databaseConnection.models.Bucket.findOne({ _id: response.body.id }) + expect(bucketInDatabase).not.toBeNull() + expect(bucketInDatabase.userId).toBe(testUser.uuid) + expect(bucketInDatabase.name).toBe(bucketName) + }) + + it('When creating a bucket with a name that already exists, then it returns the existing bucket', async () => { + const testUser = await createTestUser() + const bucketName = `mail-account-${crypto.randomUUID()}` + + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const firstResponse = await testServer + .post(`/v2/gateway/users/${testUser.uuid}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({ name: bucketName }) + + const secondResponse = await testServer + .post(`/v2/gateway/users/${testUser.uuid}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({ name: bucketName }) + + expect(firstResponse.status).toBe(200) + expect(secondResponse.status).toBe(200) + expect(secondResponse.body.id).toBe(firstResponse.body.id) + + const buckets = await databaseConnection.models.Bucket.find({ userId: testUser.uuid, name: bucketName }) + expect(buckets.length).toBe(1) + }) + + it('When creating a bucket without a name, then it returns 400', async () => { + const testUser = await createTestUser() + + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const response = await testServer + .post(`/v2/gateway/users/${testUser.uuid}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({}) + + expect(response.status).toBe(400) + }) + + it('When creating a bucket for an unknown user, then it returns 404', async () => { + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const response = await testServer + .post(`/v2/gateway/users/${crypto.randomUUID()}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({ name: 'mail-account' }) + + expect(response.status).toBe(404) + }) + }) + + describe('Deleting a user bucket', () => { + it('When deleting an existing bucket, then it is removed from the database', async () => { + const testUser = await createTestUser() + const bucketName = `mail-account-${crypto.randomUUID()}` + + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const { body: bucket } = await testServer + .post(`/v2/gateway/users/${testUser.uuid}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({ name: bucketName }) + + const response = await testServer + .delete(`/v2/gateway/users/${testUser.uuid}/buckets/${bucket.id}`) + .set('Authorization', `Bearer ${jwt}`) + + expect(response.status).toBe(204) + + const bucketInDatabase = await databaseConnection.models.Bucket.findOne({ _id: bucket.id }) + expect(bucketInDatabase).toBeNull() + }) + + it('When deleting a bucket for an unknown user, then it returns 404', async () => { + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const response = await testServer + .delete(`/v2/gateway/users/${crypto.randomUUID()}/buckets/${crypto.randomUUID()}`) + .set('Authorization', `Bearer ${jwt}`) + + expect(response.status).toBe(404) + }) + }) + describe('Deleting user files', () => { let axiosGetStub: sinon.SinonStub const FAKE_UPLOAD_URL = 'http://fake-upload-url' From a378c979887feb26cda7a54cbdf5906c42688ce1 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:11:22 -0600 Subject: [PATCH 2/3] refactor: clarify test descriptions for bucket creation and retrieval scenarios --- tests/lib/core/users/usecase.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/lib/core/users/usecase.test.ts b/tests/lib/core/users/usecase.test.ts index 7c10c772..60ee6d75 100644 --- a/tests/lib/core/users/usecase.test.ts +++ b/tests/lib/core/users/usecase.test.ts @@ -430,7 +430,7 @@ describe('Users usecases', () => { expect(findUser.calledOnce).toBeTruthy(); }); - it('When no bucket with that name exists, then it creates one', async () => { + it('When no bucket with that name and for that user exists, then it creates one', async () => { const user = fixtures.getUser(); const name = 'mail-account-id'; const createdBucket = { id: 'new-bucket-id', name } as any; @@ -456,7 +456,7 @@ describe('Users usecases', () => { expect(result).toStrictEqual({ id: createdBucket.id, name: createdBucket.name }); }); - it('When a bucket with that name already exists, then it returns the existing one without creating', async () => { + it('When a bucket with that name and for that user already exists, then it returns the existing one without creating', async () => { const user = fixtures.getUser(); const name = 'mail-account-id'; const existingBucket = { id: 'existing-bucket-id', name } as any; From f90c78e8f14f6f1ca5957505990467dbacbd39b3 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:57:42 -0600 Subject: [PATCH 3/3] fix: remove unused code --- lib/core/users/usecase.ts | 10 ------ lib/server/http/gateway/controller.ts | 31 ------------------ lib/server/http/gateway/index.ts | 1 - tests/lib/core/users/usecase.test.ts | 30 ------------------ tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts | 33 -------------------- 5 files changed, 105 deletions(-) diff --git a/lib/core/users/usecase.ts b/lib/core/users/usecase.ts index 973307e4..4ffd06a8 100644 --- a/lib/core/users/usecase.ts +++ b/lib/core/users/usecase.ts @@ -291,14 +291,4 @@ export class UsersUsecase { return { id: created.id, name: created.name }; } - - async deleteBucket(uuid: User['uuid'], bucketId: string): Promise { - const user = await this.usersRepository.findByUuid(uuid); - - if (!user) { - throw new UserNotFoundError(uuid); - } - - await this.bucketsRepository.removeByIdAndUser(bucketId, uuid); - } } diff --git a/lib/server/http/gateway/controller.ts b/lib/server/http/gateway/controller.ts index fa86d21e..37b0d088 100644 --- a/lib/server/http/gateway/controller.ts +++ b/lib/server/http/gateway/controller.ts @@ -164,35 +164,4 @@ export class HTTPGatewayController { return res.status(500).send({ message: 'Internal server error' }); } } - - async deleteUserBucket( - req: Request<{ uuid: string; id: string }>, - res: Response<{ message: string }> - ) { - const { uuid, id } = req.params; - - if (!uuid || !id) { - return res.status(400).send({ message: 'Missing params' }); - } - - try { - await this.usersUsecase.deleteBucket(uuid, id); - - return res.status(204).end(); - } catch (err) { - if (err instanceof UserNotFoundError) { - return res.status(404).send({ message: err.message }); - } - - this.logger.error( - '[GATEWAY/DELETE_BUCKET] Error deleting bucket %s for user %s: %s. %s', - id, - uuid, - (err as Error).message, - (err as Error).stack || 'NO STACK' - ); - - return res.status(500).send({ message: 'Internal server error' }); - } - } } diff --git a/lib/server/http/gateway/index.ts b/lib/server/http/gateway/index.ts index 3e237efd..ad9155e3 100644 --- a/lib/server/http/gateway/index.ts +++ b/lib/server/http/gateway/index.ts @@ -11,7 +11,6 @@ export const createGatewayHTTPRouter = ( router.patch('/users/:uuid', jwtMiddleware, controller.updateUserEmail.bind(controller)); router.put('/storage/users/:uuid', jwtMiddleware, controller.changeStorage.bind(controller)); router.post('/users/:uuid/buckets', jwtMiddleware, controller.createUserBucket.bind(controller)); - router.delete('/users/:uuid/buckets/:id', jwtMiddleware, controller.deleteUserBucket.bind(controller)); router.delete('/storage/files', jwtMiddleware, controller.deleteFilesInBulk.bind(controller)); return router; diff --git a/tests/lib/core/users/usecase.test.ts b/tests/lib/core/users/usecase.test.ts index 60ee6d75..e49d28fb 100644 --- a/tests/lib/core/users/usecase.test.ts +++ b/tests/lib/core/users/usecase.test.ts @@ -473,36 +473,6 @@ describe('Users usecases', () => { }); }); - describe('Deleting a bucket', () => { - it('When the user does not exist, then it throws UserNotFoundError', async () => { - const findUser = stub(usersRepository, 'findByUuid').resolves(null); - const removeBucket = stub(bucketsRepository, 'removeByIdAndUser'); - - try { - await usecase.deleteBucket('unknown-uuid', 'bucket-id'); - expect(true).toBeFalsy(); - } catch (err) { - expect(err).toBeInstanceOf(UserNotFoundError); - } - - expect(findUser.calledOnce).toBeTruthy(); - expect(removeBucket.called).toBeFalsy(); - }); - - it('When the user exists, then it removes the bucket by id and user uuid', async () => { - const user = fixtures.getUser(); - const bucketId = 'bucket-id'; - - stub(usersRepository, 'findByUuid').resolves(user); - const removeBucket = stub(bucketsRepository, 'removeByIdAndUser').resolves(); - - await usecase.deleteBucket(user.uuid, bucketId); - - expect(removeBucket.calledOnce).toBeTruthy(); - expect(removeBucket.firstCall.args).toStrictEqual([bucketId, user.uuid]); - }); - }); - describe('Confirming user destruction', () => { it('When confirming a destruction of a user that exists, then it works', async () => { const user = fixtures.getUser(); diff --git a/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts b/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts index d8f73abe..6b0c4aa4 100644 --- a/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts +++ b/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts @@ -115,39 +115,6 @@ describe('Gateway V2 e2e tests', () => { }) }) - describe('Deleting a user bucket', () => { - it('When deleting an existing bucket, then it is removed from the database', async () => { - const testUser = await createTestUser() - const bucketName = `mail-account-${crypto.randomUUID()}` - - const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) - - const { body: bucket } = await testServer - .post(`/v2/gateway/users/${testUser.uuid}/buckets`) - .set('Authorization', `Bearer ${jwt}`) - .send({ name: bucketName }) - - const response = await testServer - .delete(`/v2/gateway/users/${testUser.uuid}/buckets/${bucket.id}`) - .set('Authorization', `Bearer ${jwt}`) - - expect(response.status).toBe(204) - - const bucketInDatabase = await databaseConnection.models.Bucket.findOne({ _id: bucket.id }) - expect(bucketInDatabase).toBeNull() - }) - - it('When deleting a bucket for an unknown user, then it returns 404', async () => { - const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) - - const response = await testServer - .delete(`/v2/gateway/users/${crypto.randomUUID()}/buckets/${crypto.randomUUID()}`) - .set('Authorization', `Bearer ${jwt}`) - - expect(response.status).toBe(404) - }) - }) - describe('Deleting user files', () => { let axiosGetStub: sinon.SinonStub const FAKE_UPLOAD_URL = 'http://fake-upload-url'