diff --git a/src/modules/item/__test__/item.read.test.ts b/src/modules/item/__test__/item.read.test.ts index c6c863f..5fac4df 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,208 @@ describe('GET /api/item/:parentId', () => { }); }); }); + +describe('GET /api/item/:id/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 folder 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 responseFolder = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + folder.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), + 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, + }, + }); + + 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), + 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, + }, + }); + + 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), + isStarred: false, + }); + }); + + 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.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/__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 5c389ef..596b602 100644 --- a/src/modules/item/item.controller.ts +++ b/src/modules/item/item.controller.ts @@ -1,6 +1,6 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import ItemService from './item.service'; -import { ReadInput, itemSharingsInput } from './item.schema'; +import { ReadInput, itemSharingsInput, itemReadInput } from './item.schema'; import AccessService from './sharing/access.service'; export default class ItemController { @@ -68,6 +68,32 @@ export default class ItemController { } } + public async readHandler( + request: FastifyRequest<{ + Params: itemReadInput; + }>, + reply: FastifyReply, + ) { + try { + const id = Number.parseInt(request.params.id); + + if (!(await this.accessService.hasAccessToItem(id, request.user.sub))) { + return reply.unauthorized(); + } + + const item = await this.itemService.getItemByIdWithInclude(id, request.user.sub); + + return reply.code(200).send(item); + } catch (e) { + if (e instanceof Error) { + return reply.badRequest(request.i18n.t(e.message)); + } + + /* istanbul ignore next */ + return reply.badRequest(); + } + } + public async sharingsHandler( request: FastifyRequest<{ Params: itemSharingsInput; diff --git a/src/modules/item/item.route.ts b/src/modules/item/item.route.ts index 689ae4d..13d3776 100644 --- a/src/modules/item/item.route.ts +++ b/src/modules/item/item.route.ts @@ -42,6 +42,25 @@ export default async (fastify: FastifyInstance) => { itemController.itemRootHandler.bind(itemController), ); + fastify.get( + '/:id/single', + { + schema: { + tags: ['Item'], + response: { + 200: { $ref: 'itemResponseSchema' }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + onRequest: [fastify.authenticate], + }, + itemController.readHandler.bind(itemController), + ); + fastify.get( '/:parentId', { diff --git a/src/modules/item/item.schema.ts b/src/modules/item/item.schema.ts index 2c16de2..234af67 100644 --- a/src/modules/item/item.schema.ts +++ b/src/modules/item/item.schema.ts @@ -66,6 +66,9 @@ const itemsResponseSchema = { blobUrl: { type: ['string', 'null'], }, + linkedItemId: { + type: ['number', 'null'], + }, parentId: { type: ['number', 'null'], }, @@ -91,6 +94,71 @@ 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', + properties: { + id: { + type: 'string', + errorMessage: { + type: 'item.id.type', + }, + }, + }, + required: ['id'], + errorMessage: { + required: { + id: 'item.id.required', + }, + }, +} as const; + const itemSharingsSchema = { $id: 'itemSharingsSchema', type: 'object', @@ -205,6 +273,12 @@ const itemSharingsResponseSchema = { } as const; export type itemSharingsInput = FromSchema; +export type itemReadInput = FromSchema; export type ReadInput = FromSchema; -export const itemSchemas = [readItemsSchema, itemsResponseSchema, itemSharingsResponseSchema]; +export const itemSchemas = [ + readItemsSchema, + itemsResponseSchema, + itemResponseSchema, + itemSharingsResponseSchema, +]; diff --git a/src/modules/item/item.service.ts b/src/modules/item/item.service.ts index 4ae2e1a..2be09a2 100644 --- a/src/modules/item/item.service.ts +++ b/src/modules/item/item.service.ts @@ -191,19 +191,47 @@ export default class ItemService { return item; } + public async getItemByIdWithInclude(id: number, userId: number): Promise { + const item = await prisma.item.findUnique({ + where: { + id, + }, + include: { + ItemBlob: true, + ItemFolder: true, + ItemDocs: true, + ItemShortcut: true, + ItemStarred: { + where: { + userId: userId, + }, + }, + }, + }); + + 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); }); } }