From 2950728d5c51747a5200fe6f7d51da2084ceba41 Mon Sep 17 00:00:00 2001 From: Anders Rasmussen Date: Wed, 11 Oct 2023 21:46:44 +0200 Subject: [PATCH 1/3] #31 - Updated item endpoints --- src/modules/item/item.controller.ts | 11 +++++++++++ src/modules/item/item.route.ts | 19 +++++++++++++++++++ src/modules/item/item.service.ts | 23 +++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/modules/item/item.controller.ts b/src/modules/item/item.controller.ts index 33e847f..52a7abf 100644 --- a/src/modules/item/item.controller.ts +++ b/src/modules/item/item.controller.ts @@ -23,6 +23,17 @@ export default class ItemController { } } + public async browseHandler(request: FastifyRequest, reply: FastifyReply) { + try { + const items = await this.itemService.getByOwnerId(request.user.sub); + + return reply.code(200).send(items); + } catch (e) { + /* istanbul ignore next */ + return reply.badRequest(); + } + } + public async itemRootHandler(request: FastifyRequest, reply: FastifyReply) { try { const items = await this.itemService.getByOwnerIdAndParentId(request.user.sub, null); diff --git a/src/modules/item/item.route.ts b/src/modules/item/item.route.ts index 2cfbe65..54c5343 100644 --- a/src/modules/item/item.route.ts +++ b/src/modules/item/item.route.ts @@ -49,6 +49,25 @@ export default async (fastify: FastifyInstance) => { itemController.itemRootHandler.bind(itemController), ); + fastify.get( + '/folders', + { + schema: { + tags: ['Item'], + response: { + 200: { $ref: 'itemsResponseSchema' }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + onRequest: [fastify.authenticate], + }, + itemController.browseHandler.bind(itemController), + ); + fastify.get( '/:parentId', { diff --git a/src/modules/item/item.service.ts b/src/modules/item/item.service.ts index ebc9a98..3667c4c 100644 --- a/src/modules/item/item.service.ts +++ b/src/modules/item/item.service.ts @@ -54,6 +54,29 @@ export default class ItemService { return this.formatItems(items); } + public async getByOwnerId( + ownerId: number, + ): Promise { + const items = await prisma.item.findMany({ + where: { + ownerId: ownerId, + }, + include: { + ItemBlob: true, + ItemFolder: true, + ItemDocs: true, + ItemShortcut: true, + ItemStarred: { + where: { + userId: ownerId, + }, + }, + }, + }); + + return this.formatItems(items); + } + public async getAllOwnedAndSharredItemsByParentIdAndUserIdRecursively( userId: number, parentId: number | null, From bf902cf7e39222fed9a7f41d80d94a4e7d39b271 Mon Sep 17 00:00:00 2001 From: Anders Rasmussen Date: Tue, 17 Oct 2023 08:39:53 +0200 Subject: [PATCH 2/3] #31 - Added tests --- src/modules/item/__test__/item.read.test.ts | 210 ++++++++++++++++++ src/modules/item/__test__/item.root.test.ts | 1 + src/modules/item/__test__/item.shared.test.ts | 1 + src/modules/item/item.controller.ts | 11 - src/modules/item/item.route.ts | 19 -- src/modules/item/item.schema.ts | 40 ++-- src/modules/item/item.service.ts | 27 --- 7 files changed, 233 insertions(+), 76 deletions(-) diff --git a/src/modules/item/__test__/item.read.test.ts b/src/modules/item/__test__/item.read.test.ts index c6c863f..e1ec8b9 100644 --- a/src/modules/item/__test__/item.read.test.ts +++ b/src/modules/item/__test__/item.read.test.ts @@ -137,6 +137,7 @@ describe('GET /api/item/:parentId', () => { deletedAt: null, updatedAt: expect.any(String), isStarred: false, + linkedItemId: blob.id, }, ]); }); @@ -228,3 +229,212 @@ describe('GET /api/item/:parentId', () => { }); }); }); + +describe('GET /api/item/:parentId/single', () => { + let userService: UserService; + let folderService: FolderService; + let authService: AuthService; + let blobService: BlobService; + let docsService: DocsService; + + let user: User; + let otherUser: User; + + beforeAll(async () => { + userService = UserServiceFactory.make(); + folderService = FolderServiceFactory.make(); + authService = AuthServiceFactory.make(); + blobService = BlobServiceFactory.make(); + docsService = DocsServiceFactory.make(); + + user = await userService.createUser({ + name: 'Joe Biden the 4th', + email: 'joe3@biden.com', + password: '1234', + }); + otherUser = await userService.createUser({ + name: 'Joe Biden the 3rd', + email: 'joe4@biden.com', + password: '4321', + }); + }); + + it('Should return status 200 and item from id', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: user.id, + parentId: null, + }); + + const blob = await blobService.createBlob({ + mimeType: 'text/plain', + name: 'test1.txt', + ownerId: user.id, + parentId: null, + blobUrl: 'https://example.com/test1.txt', + }); + + const docs = await docsService.createDocs({ + name: 'Docser', + ownerId: user.id, + parentId: null, + text: 'Docs text here!', + }); + + const responseFolder = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + folder.id + '/single', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + const responseBlob = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + blob.id + '/single', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + const responseDocs = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + docs.id + '/single', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(responseFolder.statusCode).toBe(200); + expect(responseFolder.json()).toEqual({ + id: expect.any(Number), + name: 'Folder1', + parentId: null, + ownerId: user.id, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + ItemBlob: null, + ItemFolder: { + id: expect.any(Number), + color: '#123456', + }, + ItemDocs: null, + }); + + expect(responseBlob.statusCode).toBe(200); + expect(responseBlob.json()).toEqual({ + id: expect.any(Number), + name: 'test1.txt', + parentId: null, + ownerId: user.id, + mimeType: 'text/plain', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + ItemBlob: { + id: expect.any(Number), + blobUrl: 'https://example.com/test1.txt', + }, + ItemFolder: null, + ItemDocs: null, + }); + + expect(responseDocs.statusCode).toBe(200); + expect(responseDocs.json()).toEqual({ + id: expect.any(Number), + name: 'Docser', + parentId: null, + ownerId: user.id, + mimeType: 'application/vnd.cloudstore.docs', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + ItemBlob: null, + ItemFolder: null, + ItemDocs: { + id: expect.any(Number), + text: 'Docs text here!', + }, + }); + }); + + it('Should return status 400, when item not found', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/item/1234/single', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'BadRequestError', + errors: { + _: ['Item not found'], + }, + statusCode: 400, + }); + }); + + it('Should return status 401, when unauthorized', async () => { + const folder = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: user.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + folder.id + '/single', + headers: { + authorization: 'WrongAuth!', + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it('Should return status 401, when no access to file', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: otherUser.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + folder.id + '/single', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); +}); diff --git a/src/modules/item/__test__/item.root.test.ts b/src/modules/item/__test__/item.root.test.ts index a19a8b0..4dea284 100644 --- a/src/modules/item/__test__/item.root.test.ts +++ b/src/modules/item/__test__/item.root.test.ts @@ -142,6 +142,7 @@ describe('GET /api/item', () => { deletedAt: null, updatedAt: expect.any(String), isStarred: false, + linkedItemId: folder.id, }, ]); }); diff --git a/src/modules/item/__test__/item.shared.test.ts b/src/modules/item/__test__/item.shared.test.ts index ef21a9d..19f104e 100644 --- a/src/modules/item/__test__/item.shared.test.ts +++ b/src/modules/item/__test__/item.shared.test.ts @@ -136,6 +136,7 @@ describe('GET /api/item/shared', () => { createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), + linkedItemId: blob.id, }, { id: expect.any(Number), diff --git a/src/modules/item/item.controller.ts b/src/modules/item/item.controller.ts index 66e94fb..64577a0 100644 --- a/src/modules/item/item.controller.ts +++ b/src/modules/item/item.controller.ts @@ -23,17 +23,6 @@ export default class ItemController { } } - public async browseHandler(request: FastifyRequest, reply: FastifyReply) { - try { - const items = await this.itemService.getByOwnerId(request.user.sub); - - return reply.code(200).send(items); - } catch (e) { - /* istanbul ignore next */ - return reply.badRequest(); - } - } - public async itemRootHandler(request: FastifyRequest, reply: FastifyReply) { try { const items = await this.itemService.getByOwnerIdAndParentId(request.user.sub, null); diff --git a/src/modules/item/item.route.ts b/src/modules/item/item.route.ts index 789d976..6d7650c 100644 --- a/src/modules/item/item.route.ts +++ b/src/modules/item/item.route.ts @@ -61,25 +61,6 @@ export default async (fastify: FastifyInstance) => { itemController.readHandler.bind(itemController), ); - fastify.get( - '/folders', - { - schema: { - tags: ['Item'], - response: { - 200: { $ref: 'itemsResponseSchema' }, - }, - security: [ - { - bearerAuth: [], - }, - ], - }, - onRequest: [fastify.authenticate], - }, - itemController.browseHandler.bind(itemController), - ); - fastify.get( '/:parentId', { diff --git a/src/modules/item/item.schema.ts b/src/modules/item/item.schema.ts index bd41dbb..4881cf6 100644 --- a/src/modules/item/item.schema.ts +++ b/src/modules/item/item.schema.ts @@ -240,7 +240,7 @@ const itemFolderDocsBlobResponseSchema = { type: 'string', }, ownerId: { - type: 'string', + type: 'number', }, parentId: { type: ['number', 'null'], @@ -258,26 +258,23 @@ const itemFolderDocsBlobResponseSchema = { }, ItemBlob: { type: ['object', 'null'], - properties: { - id: { - type: 'number', - }, - blobUrl: { - type: 'string', - }, - }, + properties: { + id: { + type: 'number', + }, + blobUrl: { + type: 'string', + }, + }, }, ItemDocs: { type: ['object', 'null'], - items: { - type: 'object', - properties: { - id: { - type: 'number', - }, - text: { - type: 'string', - }, + properties: { + id: { + type: 'number', + }, + text: { + type: 'string', }, }, }, @@ -297,4 +294,9 @@ export type itemSharingsInput = FromSchema; export type itemReadInput = FromSchema; export type ReadInput = FromSchema; -export const itemSchemas = [readItemsSchema, itemsResponseSchema, itemSharingsResponseSchema, itemFolderDocsBlobResponseSchema]; +export const itemSchemas = [ + readItemsSchema, + itemsResponseSchema, + itemSharingsResponseSchema, + itemFolderDocsBlobResponseSchema, +]; diff --git a/src/modules/item/item.service.ts b/src/modules/item/item.service.ts index ea7d05d..11ee829 100644 --- a/src/modules/item/item.service.ts +++ b/src/modules/item/item.service.ts @@ -54,29 +54,6 @@ export default class ItemService { return this.formatItems(items); } - public async getByOwnerId( - ownerId: number, - ): Promise { - const items = await prisma.item.findMany({ - where: { - ownerId: ownerId, - }, - include: { - ItemBlob: true, - ItemFolder: true, - ItemDocs: true, - ItemShortcut: true, - ItemStarred: { - where: { - userId: ownerId, - }, - }, - }, - }); - - return this.formatItems(items); - } - public async getAllOwnedAndSharredItemsByParentIdAndUserIdRecursively( userId: number, parentId: number | null, @@ -226,10 +203,6 @@ export default class ItemService { }, }); - if (!item) { - throw new Error('item.notFound'); - } - return item; } From c5c5563ddd46daad937b1d78c9b1918d2dfb6375 Mon Sep 17 00:00:00 2001 From: Anders Rasmussen Date: Tue, 17 Oct 2023 10:16:43 +0200 Subject: [PATCH 3/3] #31 - Added changes according to feedback --- src/modules/item/__test__/item.read.test.ts | 94 +++++++-------- .../item/__test__/item.service.test.ts | 6 + src/modules/item/item.controller.ts | 2 +- src/modules/item/item.route.ts | 2 +- src/modules/item/item.schema.ts | 112 ++++++++---------- src/modules/item/item.service.ts | 39 ++++-- 6 files changed, 126 insertions(+), 129 deletions(-) diff --git a/src/modules/item/__test__/item.read.test.ts b/src/modules/item/__test__/item.read.test.ts index e1ec8b9..5fac4df 100644 --- a/src/modules/item/__test__/item.read.test.ts +++ b/src/modules/item/__test__/item.read.test.ts @@ -230,7 +230,7 @@ describe('GET /api/item/:parentId', () => { }); }); -describe('GET /api/item/:parentId/single', () => { +describe('GET /api/item/:id/single', () => { let userService: UserService; let folderService: FolderService; let authService: AuthService; @@ -259,7 +259,7 @@ describe('GET /api/item/:parentId/single', () => { }); }); - it('Should return status 200 and item from id', async () => { + it('Should return status 200 and folder item from id', async () => { const { accessToken } = await authService.createTokens(user.id); const folder = await folderService.createFolder({ @@ -269,21 +269,6 @@ describe('GET /api/item/:parentId/single', () => { parentId: null, }); - const blob = await blobService.createBlob({ - mimeType: 'text/plain', - name: 'test1.txt', - ownerId: user.id, - parentId: null, - blobUrl: 'https://example.com/test1.txt', - }); - - const docs = await docsService.createDocs({ - name: 'Docser', - ownerId: user.id, - parentId: null, - text: 'Docs text here!', - }); - const responseFolder = await global.fastify.inject({ method: 'GET', url: '/api/item/' + folder.id + '/single', @@ -292,74 +277,85 @@ describe('GET /api/item/:parentId/single', () => { }, }); - const responseBlob = await global.fastify.inject({ - method: 'GET', - url: '/api/item/' + blob.id + '/single', - headers: { - authorization: 'Bearer ' + accessToken, - }, - }); - - const responseDocs = await global.fastify.inject({ - method: 'GET', - url: '/api/item/' + docs.id + '/single', - headers: { - authorization: 'Bearer ' + accessToken, - }, - }); - expect(responseFolder.statusCode).toBe(200); expect(responseFolder.json()).toEqual({ id: expect.any(Number), name: 'Folder1', + color: '#123456', parentId: null, ownerId: user.id, mimeType: 'application/vnd.cloudstore.folder', createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), - ItemBlob: null, - ItemFolder: { - id: expect.any(Number), - color: '#123456', + isStarred: false, + }); + }); + + it('Should return status 200 and blob item from id', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const blob = await blobService.createBlob({ + mimeType: 'text/plain', + name: 'test1.txt', + ownerId: user.id, + parentId: null, + blobUrl: 'https://example.com/test1.txt', + }); + + const responseBlob = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + blob.id + '/single', + headers: { + authorization: 'Bearer ' + accessToken, }, - ItemDocs: null, }); expect(responseBlob.statusCode).toBe(200); expect(responseBlob.json()).toEqual({ id: expect.any(Number), name: 'test1.txt', + blobUrl: 'https://example.com/test1.txt', parentId: null, ownerId: user.id, mimeType: 'text/plain', createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), - ItemBlob: { - id: expect.any(Number), - blobUrl: 'https://example.com/test1.txt', + isStarred: false, + }); + }); + + it('Should return status 200 and docs item from id', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const docs = await docsService.createDocs({ + name: 'Docser', + ownerId: user.id, + parentId: null, + text: 'Docs text here!', + }); + + const responseDocs = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + docs.id + '/single', + headers: { + authorization: 'Bearer ' + accessToken, }, - ItemFolder: null, - ItemDocs: null, }); expect(responseDocs.statusCode).toBe(200); expect(responseDocs.json()).toEqual({ id: expect.any(Number), name: 'Docser', + text: 'Docs text here!', parentId: null, ownerId: user.id, mimeType: 'application/vnd.cloudstore.docs', createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), - ItemBlob: null, - ItemFolder: null, - ItemDocs: { - id: expect.any(Number), - text: 'Docs text here!', - }, + isStarred: false, }); }); diff --git a/src/modules/item/__test__/item.service.test.ts b/src/modules/item/__test__/item.service.test.ts index 847cbb5..0e9669d 100644 --- a/src/modules/item/__test__/item.service.test.ts +++ b/src/modules/item/__test__/item.service.test.ts @@ -400,4 +400,10 @@ describe('ItemService', () => { await expect(itemService.getItemByIdWithSharingsAndOwner(1234)).rejects.toThrow(); }); }); + + describe('getItemByIdWithInclude()', () => { + it("should throw error, when item doesn't exist", async () => { + await expect(itemService.getItemByIdWithInclude(1234, user.id)).rejects.toThrow(); + }); + }); }); diff --git a/src/modules/item/item.controller.ts b/src/modules/item/item.controller.ts index 64577a0..596b602 100644 --- a/src/modules/item/item.controller.ts +++ b/src/modules/item/item.controller.ts @@ -81,7 +81,7 @@ export default class ItemController { return reply.unauthorized(); } - const item = await this.itemService.getItemByIdWithFolderAndDocsAndBlob(id); + const item = await this.itemService.getItemByIdWithInclude(id, request.user.sub); return reply.code(200).send(item); } catch (e) { diff --git a/src/modules/item/item.route.ts b/src/modules/item/item.route.ts index 6d7650c..13d3776 100644 --- a/src/modules/item/item.route.ts +++ b/src/modules/item/item.route.ts @@ -48,7 +48,7 @@ export default async (fastify: FastifyInstance) => { schema: { tags: ['Item'], response: { - 200: { $ref: 'itemFolderDocsBlobResponseSchema' }, + 200: { $ref: 'itemResponseSchema' }, }, security: [ { diff --git a/src/modules/item/item.schema.ts b/src/modules/item/item.schema.ts index 4881cf6..234af67 100644 --- a/src/modules/item/item.schema.ts +++ b/src/modules/item/item.schema.ts @@ -94,6 +94,52 @@ const itemsResponseSchema = { }, } as const; +const itemResponseSchema = { + $id: 'itemResponseSchema', + type: 'object', + properties: { + id: { + type: 'number', + }, + name: { + type: 'string', + }, + color: { + type: ['string', 'null'], + }, + text: { + type: ['string', 'null'], + }, + blobUrl: { + type: ['string', 'null'], + }, + linkedItemId: { + type: ['number', 'null'], + }, + parentId: { + type: ['number', 'null'], + }, + isStarred: { + type: 'boolean', + }, + mimeType: { + type: 'string', + }, + ownerId: { + type: 'number', + }, + deletedAt: { + type: ['string', 'null'], + }, + createdAt: { + type: 'string', + }, + updatedAt: { + type: 'string', + }, + }, +} as const; + const itemReadSchema = { $id: 'itemReadSchema', type: 'object', @@ -226,70 +272,6 @@ const itemSharingsResponseSchema = { }, } as const; -const itemFolderDocsBlobResponseSchema = { - $id: 'itemFolderDocsBlobResponseSchema', - type: 'object', - properties: { - id: { - type: 'number', - }, - name: { - type: 'string', - }, - mimeType: { - type: 'string', - }, - ownerId: { - type: 'number', - }, - parentId: { - type: ['number', 'null'], - }, - ItemFolder: { - type: ['object', 'null'], - properties: { - id: { - type: 'number', - }, - color: { - type: 'string', - }, - }, - }, - ItemBlob: { - type: ['object', 'null'], - properties: { - id: { - type: 'number', - }, - blobUrl: { - type: 'string', - }, - }, - }, - ItemDocs: { - type: ['object', 'null'], - properties: { - id: { - type: 'number', - }, - text: { - type: 'string', - }, - }, - }, - deletedAt: { - type: ['string', 'null'], - }, - createdAt: { - type: 'string', - }, - updatedAt: { - type: 'string', - }, - }, -} as const; - export type itemSharingsInput = FromSchema; export type itemReadInput = FromSchema; export type ReadInput = FromSchema; @@ -297,6 +279,6 @@ export type ReadInput = FromSchema; export const itemSchemas = [ readItemsSchema, itemsResponseSchema, + itemResponseSchema, itemSharingsResponseSchema, - itemFolderDocsBlobResponseSchema, ]; diff --git a/src/modules/item/item.service.ts b/src/modules/item/item.service.ts index 11ee829..2be09a2 100644 --- a/src/modules/item/item.service.ts +++ b/src/modules/item/item.service.ts @@ -191,7 +191,7 @@ export default class ItemService { return item; } - public async getItemByIdWithFolderAndDocsAndBlob(id: number) { + public async getItemByIdWithInclude(id: number, userId: number): Promise { const item = await prisma.item.findUnique({ where: { id, @@ -200,25 +200,38 @@ export default class ItemService { ItemBlob: true, ItemFolder: true, ItemDocs: true, + ItemShortcut: true, + ItemStarred: { + where: { + userId: userId, + }, + }, }, }); - return item; + if (!item) { + throw new Error('item.notFound'); + } + + return this.formatItem(item); + } + + private formatItem(item: ItemPrismaProperties): ItemWithProperties { + const { ItemFolder, ItemBlob, ItemDocs, ItemShortcut, ItemStarred, ...strippedElement } = item; + + return { + ...ItemBlob, + ...ItemFolder, + ...ItemDocs, + ...ItemShortcut, + ...strippedElement, + isStarred: ItemStarred.length > 0, + }; } private formatItems(items: ItemPrismaProperties[]): ItemWithProperties[] { return items.map((element) => { - const { ItemFolder, ItemBlob, ItemDocs, ItemShortcut, ItemStarred, ...strippedElement } = - element; - - return { - ...ItemBlob, - ...ItemFolder, - ...ItemDocs, - ...ItemShortcut, - ...strippedElement, - isStarred: ItemStarred.length > 0, - }; + return this.formatItem(element); }); } }