diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index 4ef2bb89f..7a9599955 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -13,6 +13,7 @@ import { TeamEnvironmentsModule } from './team-environments/team-environments.mo import { TeamCollectionModule } from './team-collection/team-collection.module'; import { TeamRequestModule } from './team-request/team-request.module'; import { TeamInvitationModule } from './team-invitation/team-invitation.module'; +import { ShortcodeModule } from './shortcode/shortcode.module'; @Module({ imports: [ @@ -55,6 +56,7 @@ import { TeamInvitationModule } from './team-invitation/team-invitation.module'; TeamCollectionModule, TeamRequestModule, TeamInvitationModule, + ShortcodeModule, ], providers: [GQLComplexityPlugin], }) diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 82a70233a..0a775fdbf 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -155,16 +155,29 @@ export const TEAM_INVITE_EMAIL_DO_NOT_MATCH = export const TEAM_INVITE_NOT_VALID_VIEWER = 'team_invite/not_valid_viewer' as const; +/** + * ShortCode not found in DB + * (ShortcodeService) + */ export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const; +/** + * Invalid ShortCode format + * (ShortcodeService) + */ export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const; +/** + * ShortCode already exists in DB + * (ShortcodeService) + */ +export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const; + /** * Invalid or non-existent TEAM ENVIRONMMENT ID * (TeamEnvironmentsService) */ -export const TEAM_ENVIRONMENT_NOT_FOUND = - 'team_environment/not_found' as const; +export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const; /** * The user is not a member of the team of the given environment diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index 40a0f27ca..1a9088a6f 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -7,6 +7,7 @@ import { TeamEnvironment } from 'src/team-environments/team-environments.model'; import { TeamCollection } from 'src/team-collection/team-collection.model'; import { TeamRequest } from 'src/team-request/team-request.model'; import { TeamInvitation } from 'src/team-invitation/team-invitation.model'; +import { Shortcode } from 'src/shortcode/shortcode.model'; // A custom message type that defines the topic and the corresponding payload. // For every module that publishes a subscription add its type def and the possible subscription type. @@ -36,4 +37,5 @@ export type TopicDef = { [topic: `team_req/${string}/req_deleted`]: string; [topic: `team/${string}/invite_added`]: TeamInvitation; [topic: `team/${string}/invite_removed`]: string; + [topic: `shortcode/${string}/${'created' | 'revoked'}`]: Shortcode; }; diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts index 58b6dd006..eb696a23b 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; - +import { JwtModule } from '@nestjs/jwt'; import { PrismaModule } from 'src/prisma/prisma.module'; import { PubSubModule } from 'src/pubsub/pubsub.module'; import { UserModule } from 'src/user/user.module'; @@ -7,7 +7,14 @@ import { ShortcodeResolver } from './shortcode.resolver'; import { ShortcodeService } from './shortcode.service'; @Module({ - imports: [PrismaModule, UserModule, PubSubModule], + imports: [ + PrismaModule, + UserModule, + PubSubModule, + JwtModule.register({ + secret: process.env.JWT_SECRET, + }), + ], providers: [ShortcodeService, ShortcodeResolver], exports: [ShortcodeService], }) diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts index bd8171a67..33376b5ac 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts @@ -7,23 +7,19 @@ import { Resolver, Subscription, } from '@nestjs/graphql'; -import { pipe } from 'fp-ts/function'; import * as E from 'fp-ts/Either'; -import * as T from 'fp-ts/Task'; -import * as TO from 'fp-ts/TaskOption'; -import * as TE from 'fp-ts/TaskEither'; import { UseGuards } from '@nestjs/common'; - import { Shortcode } from './shortcode.model'; import { ShortcodeService } from './shortcode.service'; import { UserService } from 'src/user/user.service'; import { throwErr } from 'src/utils'; -import { SHORTCODE_INVALID_JSON } from 'src/errors'; import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; import { User } from 'src/user/user.model'; import { PubSubService } from 'src/pubsub/pubsub.service'; import { AuthUser } from '../types/AuthUser'; +import { JwtService } from '@nestjs/jwt'; +import { PaginationArgs } from 'src/types/input-types.args'; @Resolver(() => Shortcode) export class ShortcodeResolver { @@ -31,155 +27,95 @@ export class ShortcodeResolver { private readonly shortcodeService: ShortcodeService, private readonly userService: UserService, private readonly pubsub: PubSubService, + private jwtService: JwtService, ) {} /* Queries */ - @Query(() => Shortcode, { description: 'Resolves and returns a shortcode data', nullable: true, }) - shortcode( + async shortcode( @Args({ name: 'code', type: () => ID, description: 'The shortcode to resolve', }) code: string, - ): Promise { - return pipe( - this.shortcodeService.resolveShortcode(code), - TO.getOrElseW(() => T.of(null)), - )(); + ) { + const result = await this.shortcodeService.getShortCode(code); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; } @Query(() => [Shortcode], { description: 'List all shortcodes the current user has generated', }) @UseGuards(GqlAuthGuard) - myShortcodes( - @GqlUser() user: AuthUser, - @Args({ - name: 'cursor', - type: () => ID, - description: - 'The ID of the last returned shortcode (used for pagination)', - nullable: true, - }) - cursor?: string, - ): Promise { - return this.shortcodeService.fetchUserShortCodes( - user.uid, - cursor ?? null, - )(); + async myShortcodes(@GqlUser() user: AuthUser, @Args() args: PaginationArgs) { + return this.shortcodeService.fetchUserShortCodes(user.uid, args); } /* Mutations */ + @Mutation(() => Shortcode, { + description: 'Create a shortcode for the given request.', + }) + async createShortcode( + @Args({ + name: 'request', + description: 'JSON string of the request object', + }) + request: string, + @Context() ctx: any, + ) { + const decodedAccessToken = this.jwtService.verify( + ctx.req.cookies['access_token'], + ); + const result = await this.shortcodeService.createShortcode( + request, + decodedAccessToken?.sub, + ); - // TODO: Create a shortcode resolver pending implementation - // @Mutation(() => Shortcode, { - // description: 'Create a shortcode for the given request.', - // }) - // createShortcode( - // @Args({ - // name: 'request', - // description: 'JSON string of the request object', - // }) - // request: string, - // @Context() ctx: any, - // ): Promise { - // return pipe( - // TE.Do, - // - // // Get the user - // TE.bind('user', () => - // pipe( - // TE.tryCatch( - // () => { - // const authString: string | undefined | null = - // ctx.reqHeaders.authorization; - // - // if ( - // !authString || - // !authString.includes(' ') || - // !authString.startsWith('Bearer ') - // ) { - // return Promise.reject('no auth token'); - // } - // - // const authToken = authString.split(' ')[1]; - // - // return this.userService.authenticateWithIDToken(authToken); - // }, - // (e) => e, - // ), - // TE.getOrElseW(() => T.of(undefined)), - // TE.fromTask, - // ), - // ), - // - // // Get the Request JSON - // TE.bind('reqJSON', () => - // pipe( - // E.tryCatch( - // () => JSON.parse(request), - // () => SHORTCODE_INVALID_JSON, - // ), - // TE.fromEither, - // ), - // ), - // - // // Create the shortcode - // TE.chain(({ reqJSON, user }) => { - // return TE.fromTask( - // this.shortcodeService.createShortcode(reqJSON, user), - // ); - // }), - // - // // Return or throw if there is an error - // TE.getOrElse(throwErr), - // )(); - // } + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } - // TODO: Implement revoke shortcode - // @Mutation(() => Boolean, { - // description: 'Revoke a user generated shortcode', - // }) - // @UseGuards(GqlAuthGuard) - // revokeShortcode( - // @GqlUser() user: User, - // @Args({ - // name: 'code', - // type: () => ID, - // description: 'The shortcode to resolve', - // }) - // code: string, - // ): Promise { - // return pipe( - // this.shortcodeService.revokeShortCode(code, user.uid), - // TE.map(() => true), // Just return true on success, no resource to return - // TE.getOrElse(throwErr), - // )(); - // } + @Mutation(() => Boolean, { + description: 'Revoke a user generated shortcode', + }) + @UseGuards(GqlAuthGuard) + async revokeShortcode( + @GqlUser() user: User, + @Args({ + name: 'code', + type: () => ID, + description: 'The shortcode to resolve', + }) + code: string, + ) { + const result = await this.shortcodeService.revokeShortCode(code, user.uid); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } /* Subscriptions */ + @Subscription(() => Shortcode, { + description: 'Listen for shortcode creation', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + myShortcodesCreated(@GqlUser() user: AuthUser) { + return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`); + } - // TODO: update subscription after fixing service methods - // @Subscription(() => Shortcode, { - // description: 'Listen for shortcode creation', - // resolve: (value) => value, - // }) - // @UseGuards(GqlAuthGuard) - // myShortcodesCreated(@GqlUser() user: AuthUser) { - // return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`); - // } - // - // @Subscription(() => Shortcode, { - // description: 'Listen for shortcode deletion', - // resolve: (value) => value, - // }) - // @UseGuards(GqlAuthGuard) - // myShortcodesRevoked(@GqlUser() user: AuthUser): AsyncIterator { - // return this.pubsub.asyncIterator(`shortcode/${user.uid}/revoked`); - // } + @Subscription(() => Shortcode, { + description: 'Listen for shortcode deletion', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + myShortcodesRevoked(@GqlUser() user: AuthUser): AsyncIterator { + return this.pubsub.asyncIterator(`shortcode/${user.uid}/revoked`); + } } diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts index 57455fd6b..b1dac3825 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts @@ -1,8 +1,10 @@ import { mockDeep, mockReset } from 'jest-mock-extended'; import { PrismaService } from '../prisma/prisma.service'; - -import { SHORTCODE_NOT_FOUND } from 'src/errors'; -import { User } from 'src/user/user.model'; +import { + SHORTCODE_ALREADY_EXISTS, + SHORTCODE_INVALID_JSON, + SHORTCODE_NOT_FOUND, +} from 'src/errors'; import { Shortcode } from './shortcode.model'; import { ShortcodeService } from './shortcode.service'; import { UserService } from 'src/user/user.service'; @@ -34,258 +36,76 @@ beforeEach(() => { mockReset(mockPrisma); mockPubSub.publish.mockClear(); }); +const createdOn = new Date(); + +const shortCodeWithOutUser = { + id: '123', + request: '{}', + createdOn: createdOn, + creatorUid: null, +}; + +const shortCodeWithUser = { + id: '123', + request: '{}', + createdOn: createdOn, + creatorUid: 'user_uid_1', +}; + +const shortcodes = [ + { + id: 'blablabla', + request: { + hello: 'there', + }, + creatorUid: 'testuser', + createdOn: new Date(), + }, + { + id: 'blablabla1', + request: { + hello: 'there', + }, + creatorUid: 'testuser', + createdOn: new Date(), + }, +]; describe('ShortcodeService', () => { - describe('resolveShortcode', () => { - test('returns Some for a valid existent shortcode', () => { - mockPrisma.shortcode.findFirst.mockResolvedValueOnce({ - id: 'blablablabla', - createdOn: new Date(), - request: { - hello: 'there', - }, - creatorUid: 'testuser', - }); + describe('getShortCode', () => { + test('should return a valid shortcode with valid shortcode ID', async () => { + mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce( + shortCodeWithOutUser, + ); - return expect( - shortcodeService.resolveShortcode('blablablabla')(), - ).resolves.toBeSome(); - }); - - test('returns the correct info for a valid shortcode', () => { - const shortcode = { - id: 'blablablabla', - createdOn: new Date(), - request: { - hello: 'there', - }, - creatorUid: 'testuser', - }; - - mockPrisma.shortcode.findFirst.mockResolvedValueOnce(shortcode); - - return expect( - shortcodeService.resolveShortcode('blablablabla')(), - ).resolves.toEqualSome({ - id: shortcode.id, - request: JSON.stringify(shortcode.request), - createdOn: shortcode.createdOn, + const result = await shortcodeService.getShortCode( + shortCodeWithOutUser.id, + ); + expect(result).toEqualRight({ + id: shortCodeWithOutUser.id, + createdOn: shortCodeWithOutUser.createdOn, + request: JSON.stringify(shortCodeWithOutUser.request), }); }); - test('returns None for non-existent shortcode', () => { - mockPrisma.shortcode.findFirst.mockResolvedValueOnce(null); + test('should throw SHORTCODE_NOT_FOUND error when shortcode ID is invalid', async () => { + mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); - return expect( - shortcodeService.resolveShortcode('blablablabla')(), - ).resolves.toBeNone(); + const result = await shortcodeService.getShortCode('invalidID'); + expect(result).toEqualLeft(SHORTCODE_NOT_FOUND); }); }); - // TODO: Implement create shortcode - // describe('createShortcode', () => { - // test('creates the shortcode entry in the db', async () => { - // mockPrisma.shortcode.create.mockResolvedValueOnce({ - // id: 'itvalidreqid', - // request: { - // hello: 'there', - // }, - // creatorUid: null, - // createdOn: new Date(), - // }); - // - // await shortcodeService.createShortcode({ hello: 'there' })(); - // }); - // - // test('returns a valid Shortcode Model object', () => { - // const shortcode = { - // id: 'blablablabla', - // createdOn: new Date(), - // request: { - // hello: 'there', - // }, - // creatorUid: 'testuser', - // }; - // mockPrisma.shortcode.create.mockResolvedValueOnce(shortcode); - // - // expect( - // shortcodeService.createShortcode({ hello: 'there' })(), - // ).resolves.toEqual({ - // id: shortcode.id, - // request: JSON.stringify(shortcode.request), - // createdOn: shortcode.createdOn, - // }); - // }); - // - // test('if a creator is specified, their UID is stored in the DB', async () => { - // const testUser: User = { - // uid: 'testuid', - // displayName: 'Test User', - // email: 'test@hoppscotch.io', - // }; - // - // const shortcode = { - // id: 'blablablabla', - // createdOn: new Date(), - // request: { - // hello: 'there', - // }, - // creatorUid: testUser.uid, - // }; - // - // mockPrisma.shortcode.create.mockResolvedValueOnce(shortcode); - // - // const result = await shortcodeService.createShortcode( - // { hello: 'there' }, - // testUser, - // )(); - // - // expect(mockPrisma.shortcode.create).toHaveBeenCalledWith( - // expect.objectContaining({ - // data: { - // id: expect.any(String), - // request: { - // hello: 'there', - // }, - // creatorUid: testUser.uid, - // }, - // }), - // ); - // }); - // - // test('if a creator is not specified the creator uid is stored as null', async () => { - // mockPrisma.shortcode.create.mockResolvedValueOnce({ - // id: 'itvalidreqid', - // request: { - // hello: 'there', - // }, - // creatorUid: null, - // createdOn: new Date(), - // }); - // - // await shortcodeService.createShortcode({ hello: 'there' })(); - // - // expect(mockPrisma.shortcode.create).toHaveBeenCalledWith( - // expect.objectContaining({ - // data: { - // id: expect.any(String), - // request: { - // hello: 'there', - // }, - // creatorUid: undefined, - // }, - // }), - // ); - // }); - // - // test('generates shortcodes which are 12 character alphanumerics', async () => { - // mockPrisma.shortcode.create.mockImplementation((args) => { - // return Promise.resolve({ - // id: args.data.id, - // request: args.data.request, - // creatorUid: args.data.creatorUid, - // createdOn: args.data.createdOn, - // }) as any; - // }); - // - // // Generate 100 shortcodes - // const shortcodeEntries: Shortcode[] = []; - // for (let i = 0; i < 100; i++) { - // shortcodeEntries.push( - // await shortcodeService.createShortcode({ hello: 'there' })(), - // ); - // } - // - // expect(shortcodeEntries.every((entry) => entry.id.length === 12)).toBe( - // true, - // ); - // expect( - // shortcodeEntries.every((entry) => /^[a-zA-Z0-9]*$/.test(entry.id)), - // ).toBe(true); - // }); - // - // test('if creator is not specified, doesnt publish to pubsub anything', async () => { - // mockPrisma.shortcode.create.mockResolvedValueOnce({ - // id: 'itvalidreqid', - // request: { - // hello: 'there', - // }, - // creatorUid: null, - // createdOn: new Date(), - // }); - // - // await shortcodeService.createShortcode({ hello: 'there' })(); - // - // expect(mockPubSub.publish).not.toHaveBeenCalled(); - // }); - // - // test('if creator is specified, publishes to the proper pubsub topic `shortcode.{uid}.created`', async () => { - // const testUser: User = { - // uid: 'testuid', - // displayName: 'Test User', - // email: 'test@hoppscotch.io', - // }; - // - // const shortcode = { - // id: 'blablablabla', - // createdOn: new Date(), - // request: { - // hello: 'there', - // }, - // creatorUid: testUser.uid, - // }; - // - // mockPrisma.shortcode.create.mockResolvedValueOnce(shortcode); - // - // const result = await shortcodeService.createShortcode( - // { hello: 'there' }, - // testUser, - // )(); - // - // expect(mockPubSub.publish).toHaveBeenCalledWith( - // `shortcode/testuid/created`, - // { ...result }, - // ); - // }); - // }); - describe('fetchUserShortCodes', () => { - test('returns all shortcodes for a user with no provided cursor', async () => { - const shortcodes = [ - { - id: 'blablabla', - request: { - hello: 'there', - }, - creatorUid: 'testuser', - createdOn: new Date(), - }, - { - id: 'blablabla1', - request: { - hello: 'there', - }, - creatorUid: 'testuser', - createdOn: new Date(), - }, - ]; - mockPrisma.shortcode.findMany.mockResolvedValue(shortcodes); + test('should return list of shortcodes with valid inputs and no cursor', async () => { + mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes); - const result = await shortcodeService.fetchUserShortCodes( - 'testuser', - null, - )(); - - expect(mockPrisma.shortcode.findMany).toHaveBeenCalledWith({ + const result = await shortcodeService.fetchUserShortCodes('testuser', { + cursor: null, take: 10, - where: { - creatorUid: 'testuser', - }, - orderBy: { - createdOn: 'desc', - }, }); - expect(result).toEqual([ { id: shortcodes[0].id, @@ -300,221 +120,192 @@ describe('ShortcodeService', () => { ]); }); - test('return shortcodes for a user with a provided cursor', async () => { - const shortcodes = [ - { - id: 'blablabla1', - request: { - hello: 'there', - }, - creatorUid: 'testuser', - createdOn: new Date(), - }, - ]; - mockPrisma.shortcode.findMany.mockResolvedValue(shortcodes); + test('should return list of shortcodes with valid inputs and cursor', async () => { + mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]); - const result = await shortcodeService.fetchUserShortCodes( - 'testuser', - 'blablabla', - )(); - - expect(mockPrisma.shortcode.findMany).toHaveBeenCalledWith({ + const result = await shortcodeService.fetchUserShortCodes('testuser', { + cursor: 'blablabla', take: 10, - skip: 1, - cursor: { - id: 'blablabla', - }, - where: { - creatorUid: 'testuser', - }, - orderBy: { - createdOn: 'desc', - }, }); - expect(result).toEqual([ { - id: shortcodes[0].id, - request: JSON.stringify(shortcodes[0].request), - createdOn: shortcodes[0].createdOn, + id: shortcodes[1].id, + request: JSON.stringify(shortcodes[1].request), + createdOn: shortcodes[1].createdOn, }, ]); }); - test('returns an empty array for an invalid cursor', async () => { + test('should return an empty array for an invalid cursor', async () => { mockPrisma.shortcode.findMany.mockResolvedValue([]); - const result = await shortcodeService.fetchUserShortCodes( - 'testuser', - 'invalidcursor', - )(); + const result = await shortcodeService.fetchUserShortCodes('testuser', { + cursor: 'invalidcursor', + take: 10, + }); expect(result).toHaveLength(0); }); - test('returns an empty array for an invalid user id and null cursor', async () => { + test('should return an empty array for an invalid user id and null cursor', async () => { mockPrisma.shortcode.findMany.mockResolvedValue([]); - const result = await shortcodeService.fetchUserShortCodes( - 'invalidid', - null, - )(); + const result = await shortcodeService.fetchUserShortCodes('invalidid', { + cursor: null, + take: 10, + }); expect(result).toHaveLength(0); }); - test('returns an empty array for an invalid user id and an invalid cursor', async () => { + test('should return an empty array for an invalid user id and an invalid cursor', async () => { mockPrisma.shortcode.findMany.mockResolvedValue([]); - const result = await shortcodeService.fetchUserShortCodes( - 'invalidid', - 'invalidcursor', - )(); + const result = await shortcodeService.fetchUserShortCodes('invalidid', { + cursor: 'invalidcursor', + take: 10, + }); expect(result).toHaveLength(0); }); }); - // TODO: Implement revoke shortcode and user shortcode deletion - // describe('revokeShortCode', () => { - // test('returns details of deleted shortcode, when user uid and shortcode is valid', async () => { - // const shortcode = { - // id: 'blablablabla', - // createdOn: new Date(), - // request: { - // hello: 'there', - // }, - // creatorUid: 'testuser', - // }; - // - // mockPrisma.shortcode.delete.mockResolvedValueOnce(shortcode); - // - // const result = await shortcodeService.revokeShortCode( - // shortcode.id, - // shortcode.creatorUid, - // )(); - // - // expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({ - // where: { - // creator_uid_shortcode_unique: { - // creatorUid: shortcode.creatorUid, - // id: shortcode.id, - // }, - // }, - // }); - // - // expect(result).toEqualRight({ - // id: shortcode.id, - // request: JSON.stringify(shortcode.request), - // createdOn: shortcode.createdOn, - // }); - // }); - // - // test('returns SHORTCODE_NOT_FOUND error when shortcode is invalid and user uid is valid', async () => { - // mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); - // expect( - // shortcodeService.revokeShortCode('invalid', 'testuser')(), - // ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); - // }); - // - // test('returns SHORTCODE_NOT_FOUND error when shortcode is valid and user uid is invalid', async () => { - // mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); - // expect( - // shortcodeService.revokeShortCode('blablablabla', 'invalidUser')(), - // ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); - // }); - // - // test('returns SHORTCODE_NOT_FOUND error when both shortcode and user uid are invalid', async () => { - // mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); - // expect( - // shortcodeService.revokeShortCode('invalid', 'invalid')(), - // ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); - // }); - // - // test('if creator is specified in the deleted shortcode, pubsub message is sent to `shortcode/{uid}/revoked`', async () => { - // const shortcode = { - // id: 'blablablabla', - // createdOn: new Date(), - // request: { - // hello: 'there', - // }, - // creatorUid: 'testuser', - // }; - // - // mockPrisma.shortcode.delete.mockResolvedValueOnce(shortcode); - // - // const result = await shortcodeService.revokeShortCode( - // shortcode.id, - // shortcode.creatorUid, - // )(); - // - // expect(result).toBeRight(); - // expect(mockPubSub.publish).toHaveBeenCalledWith( - // `shortcode/testuser/revoked`, - // { ...(result as any).right }, - // ); - // }); - // }); - // - // describe('deleteUserShortcodes', () => { - // test('should return undefined when the user uid is valid and contains shortcodes data', async () => { - // const testUserUID = 'testuser1'; - // const shortcodesList = [ - // { - // id: 'blablablabla', - // createdOn: new Date(), - // request: { - // hello: 'there', - // }, - // creatorUid: testUserUID, - // }, - // ]; - // - // mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodesList); - // mockPrisma.shortcode.delete.mockResolvedValueOnce(shortcodesList[0]); - // - // const result = await shortcodeService.deleteUserShortcodes(testUserUID)(); - // - // expect(mockPrisma.shortcode.findMany).toHaveBeenCalledWith({ - // where: { - // creatorUid: testUserUID, - // }, - // }); - // - // expect(result).toBeUndefined(); - // }); - // - // test('should return undefined when user uid is valid but user has no shortcode data', async () => { - // const testUserUID = 'testuser1'; - // const shortcodesList = []; - // - // mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodesList); - // - // const result = await shortcodeService.deleteUserShortcodes(testUserUID)(); - // - // expect(mockPrisma.shortcode.findMany).toHaveBeenCalledWith({ - // where: { - // creatorUid: testUserUID, - // }, - // }); - // - // expect(result).toBeUndefined(); - // }); - // - // test('should return undefined when the user uid is invalid', async () => { - // const testUserUID = 'invalidtestuser'; - // const shortcodesList = []; - // - // mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodesList); - // const result = await shortcodeService.deleteUserShortcodes(testUserUID)(); - // - // expect(mockPrisma.shortcode.findMany).toHaveBeenCalledWith({ - // where: { - // creatorUid: testUserUID, - // }, - // }); - // - // expect(result).toBeUndefined(); - // }); - // }); + describe('createShortcode', () => { + test('should throw SHORTCODE_INVALID_JSON error if incoming request data is invalid', async () => { + const result = await shortcodeService.createShortcode( + 'invalidRequest', + 'user_uid_1', + ); + expect(result).toEqualLeft(SHORTCODE_INVALID_JSON); + }); + + test('should successfully create a new shortcode with valid user uid', async () => { + // generateUniqueShortCodeID --> getShortCode + mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser); + + const result = await shortcodeService.createShortcode('{}', 'user_uid_1'); + expect(result).toEqualRight({ + id: shortCodeWithUser.id, + createdOn: shortCodeWithUser.createdOn, + request: JSON.stringify(shortCodeWithUser.request), + }); + }); + + test('should successfully create a new shortcode with null user uid', async () => { + // generateUniqueShortCodeID --> getShortCode + mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser); + + const result = await shortcodeService.createShortcode('{}', null); + expect(result).toEqualRight({ + id: shortCodeWithUser.id, + createdOn: shortCodeWithUser.createdOn, + request: JSON.stringify(shortCodeWithOutUser.request), + }); + }); + + test('should send pubsub message to `shortcode/{uid}/created` on successful creation of shortcode', async () => { + // generateUniqueShortCodeID --> getShortCode + mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser); + + const result = await shortcodeService.createShortcode('{}', 'user_uid_1'); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `shortcode/${shortCodeWithUser.creatorUid}/created`, + { + id: shortCodeWithUser.id, + createdOn: shortCodeWithUser.createdOn, + request: JSON.stringify(shortCodeWithUser.request), + }, + ); + }); + }); + + describe('revokeShortCode', () => { + test('should return true on successful deletion of shortcode with valid inputs', async () => { + mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser); + + const result = await shortcodeService.revokeShortCode( + shortCodeWithUser.id, + shortCodeWithUser.creatorUid, + ); + + expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({ + where: { + creator_uid_shortcode_unique: { + creatorUid: shortCodeWithUser.creatorUid, + id: shortCodeWithUser.id, + }, + }, + }); + + expect(result).toEqualRight(true); + }); + + test('should return SHORTCODE_NOT_FOUND error when shortcode is invalid and user uid is valid', async () => { + mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); + expect( + shortcodeService.revokeShortCode('invalid', 'testuser'), + ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); + }); + + test('should return SHORTCODE_NOT_FOUND error when shortcode is valid and user uid is invalid', async () => { + mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); + expect( + shortcodeService.revokeShortCode('blablablabla', 'invalidUser'), + ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); + }); + + test('should return SHORTCODE_NOT_FOUND error when both shortcode and user uid are invalid', async () => { + mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); + expect( + shortcodeService.revokeShortCode('invalid', 'invalid'), + ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); + }); + + test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of shortcode', async () => { + mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser); + + const result = await shortcodeService.revokeShortCode( + shortCodeWithUser.id, + shortCodeWithUser.creatorUid, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `shortcode/${shortCodeWithUser.creatorUid}/revoked`, + { + id: shortCodeWithUser.id, + createdOn: shortCodeWithUser.createdOn, + request: JSON.stringify(shortCodeWithUser.request), + }, + ); + }); + }); + + describe('deleteUserShortCodes', () => { + test('should successfully delete all users shortcodes with valid user uid', async () => { + mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 }); + + const result = await shortcodeService.deleteUserShortCodes( + shortCodeWithUser.creatorUid, + ); + expect(result).toEqual(1); + }); + + test('should return 0 when user uid is invalid', async () => { + mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 }); + + const result = await shortcodeService.deleteUserShortCodes( + shortCodeWithUser.creatorUid, + ); + expect(result).toEqual(0); + }); + }); }); diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts index 36eff88f5..e8bcb169f 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts @@ -1,18 +1,22 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; -import { flow, pipe } from 'fp-ts/function'; import * as T from 'fp-ts/Task'; -import * as TE from 'fp-ts/TaskEither'; import * as O from 'fp-ts/Option'; import * as TO from 'fp-ts/TaskOption'; -import * as A from 'fp-ts/Array'; - +import * as E from 'fp-ts/Either'; import { PrismaService } from 'src/prisma/prisma.service'; -import { SHORTCODE_NOT_FOUND } from 'src/errors'; +import { + SHORTCODE_ALREADY_EXISTS, + SHORTCODE_INVALID_JSON, + SHORTCODE_NOT_FOUND, +} from 'src/errors'; import { User } from 'src/user/user.model'; import { UserDataHandler } from 'src/user/user.data.handler'; import { Shortcode } from './shortcode.model'; +import { Shortcode as DBShortCode } from '@prisma/client'; import { PubSubService } from 'src/pubsub/pubsub.service'; import { UserService } from 'src/user/user.service'; +import { stringToJson } from 'src/utils'; +import { PaginationArgs } from 'src/types/input-types.args'; const SHORT_CODE_LENGTH = 12; const SHORT_CODE_CHARS = @@ -39,7 +43,26 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { return undefined; } - private generateShortcodeID(): string { + /** + * Converts a Prisma Shortcode type into the Shortcode model + * + * @param shortcodeInfo Prisma Shortcode type + * @returns GQL Shortcode + */ + private returnShortCode(shortcodeInfo: DBShortCode): Shortcode { + return { + id: shortcodeInfo.id, + request: JSON.stringify(shortcodeInfo.request), + createdOn: shortcodeInfo.createdOn, + }; + } + + /** + * Generate a shortcode + * + * @returns generated shortcode + */ + private generateShortCodeID(): string { let result = ''; for (let i = 0; i < SHORT_CODE_LENGTH; i++) { result += @@ -48,189 +71,142 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { return result; } - private async generateUniqueShortcodeID(): Promise { + /** + * Check to see if ShortCode is already present in DB + * + * @returns Shortcode + */ + private async generateUniqueShortCodeID() { while (true) { - const code = this.generateShortcodeID(); + const code = this.generateShortCodeID(); - const data = await this.resolveShortcode(code)(); + const data = await this.getShortCode(code); - if (O.isNone(data)) return code; + if (E.isLeft(data)) return E.right(code); } } - resolveShortcode(shortcode: string): TO.TaskOption { - return pipe( - // The task to perform - () => this.prisma.shortcode.findFirst({ where: { id: shortcode } }), - TO.fromTask, // Convert to Task to TaskOption - TO.chain(TO.fromNullable), // Remove nullability - TO.map((data) => { - return { - id: data.id, - request: JSON.stringify(data.request), - createdOn: data.createdOn, - }; - }), - ); + /** + * Fetch details regarding a ShortCode + * + * @param shortcode ShortCode + * @returns Either of ShortCode details or error + */ + async getShortCode(shortcode: string) { + try { + const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({ + where: { id: shortcode }, + }); + return E.right(this.returnShortCode(shortcodeInfo)); + } catch (error) { + return E.left(SHORTCODE_NOT_FOUND); + } } - // TODO: Implement create shortcode and the user service method - // createShortcode(request: any, creator?: User): T.Task { - // return pipe( - // T.Do, - // - // // Get shortcode - // T.bind('shortcode', () => () => this.generateUniqueShortcodeID()), - // - // // Create - // T.chain( - // ({ shortcode }) => - // () => - // this.prisma.shortcode.create({ - // data: { - // id: shortcode, - // request: request, - // creatorUid: creator?.uid, - // }, - // }), - // ), - // - // T.chainFirst((shortcode) => async () => { - // // Only publish event if creator is not null - // if (shortcode.creatorUid) { - // this.pubsub.publish(`shortcode/${shortcode.creatorUid}/created`, < - // Shortcode - // >{ - // id: shortcode.id, - // request: JSON.stringify(shortcode.request), - // createdOn: shortcode.createdOn, - // }); - // } - // }), - // - // // Map to valid return type - // T.map( - // (data) => - // { - // id: data.id, - // request: JSON.stringify(data.request), - // createdOn: data.createdOn, - // }, - // ), - // ); - // } + /** + * Create a new ShortCode + * + * @param request JSON string of request details + * @param userUID user UID, if present + * @returns Either of ShortCode or error + */ + async createShortcode(request: string, userUID: string | null) { + const shortcodeData = stringToJson(request); + if (E.isLeft(shortcodeData)) return E.left(SHORTCODE_INVALID_JSON); - fetchUserShortCodes(uid: string, cursor: string | null) { - return pipe( - cursor, - O.fromNullable, - O.fold( - () => - pipe( - () => - this.prisma.shortcode.findMany({ - take: 10, - where: { - creatorUid: uid, - }, - orderBy: { - createdOn: 'desc', - }, - }), - T.map((codes) => - codes.map( - (data) => - { - id: data.id, - request: JSON.stringify(data.request), - createdOn: data.createdOn, - }, - ), - ), - ), - (cursor) => - pipe( - () => - this.prisma.shortcode.findMany({ - take: 10, - skip: 1, - cursor: { - id: cursor, - }, - where: { - creatorUid: uid, - }, - orderBy: { - createdOn: 'desc', - }, - }), - T.map((codes) => - codes.map( - (data) => - { - id: data.id, - request: JSON.stringify(data.request), - createdOn: data.createdOn, - }, - ), - ), - ), - ), - ); + const user = await this.userService.findUserById(userUID); + + const generatedShortCode = await this.generateUniqueShortCodeID(); + if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left); + + const createdShortCode = await this.prisma.shortcode.create({ + data: { + id: generatedShortCode.right, + request: shortcodeData.right, + creatorUid: O.isNone(user) ? null : user.value.uid, + }, + }); + + // Only publish event if creator is not null + if (createdShortCode.creatorUid) { + this.pubsub.publish( + `shortcode/${createdShortCode.creatorUid}/created`, + this.returnShortCode(createdShortCode), + ); + } + + return E.right(this.returnShortCode(createdShortCode)); } - // TODO: Implement revoke shortcode and user shortcode deletion feature - // revokeShortCode(shortcode: string, uid: string) { - // return pipe( - // TE.tryCatch( - // () => - // this.prisma.shortcode.delete({ - // where: { - // creator_uid_shortcode_unique: { - // creatorUid: uid, - // id: shortcode, - // }, - // }, - // }), - // () => SHORTCODE_NOT_FOUND, - // ), - // TE.chainFirst((shortcode) => - // TE.fromTask(() => - // this.pubsub.publish(`shortcode/${shortcode.creatorUid}/revoked`, < - // Shortcode - // >{ - // id: shortcode.id, - // request: JSON.stringify(shortcode.request), - // createdOn: shortcode.createdOn, - // }), - // ), - // ), - // TE.map( - // (data) => - // { - // id: data.id, - // request: JSON.stringify(data.request), - // createdOn: data.createdOn, - // }, - // ), - // ); - // } + /** + * Fetch ShortCodes created by a User + * + * @param uid User Uid + * @param args Pagination arguments + * @returns Array of ShortCodes + */ + async fetchUserShortCodes(uid: string, args: PaginationArgs) { + const shortCodes = await this.prisma.shortcode.findMany({ + where: { + creatorUid: uid, + }, + orderBy: { + createdOn: 'desc', + }, + skip: 1, + take: args.take, + cursor: args.cursor ? { id: args.cursor } : undefined, + }); - // deleteUserShortcodes(uid: string) { - // return pipe( - // () => - // this.prisma.shortcode.findMany({ - // where: { - // creatorUid: uid, - // }, - // }), - // T.chain( - // flow( - // A.map((shortcode) => this.revokeShortCode(shortcode.id, uid)), - // T.sequenceArray, - // ), - // ), - // T.map(() => undefined), - // ); - // } + const fetchedShortCodes: Shortcode[] = shortCodes.map((code) => + this.returnShortCode(code), + ); + + return fetchedShortCodes; + } + + /** + * Delete a ShortCode + * + * @param shortcode ShortCode + * @param uid User Uid + * @returns Boolean on successful deletion + */ + async revokeShortCode(shortcode: string, uid: string) { + try { + const deletedShortCodes = await this.prisma.shortcode.delete({ + where: { + creator_uid_shortcode_unique: { + creatorUid: uid, + id: shortcode, + }, + }, + }); + + this.pubsub.publish( + `shortcode/${deletedShortCodes.creatorUid}/revoked`, + this.returnShortCode(deletedShortCodes), + ); + + return E.right(true); + } catch (error) { + return E.left(SHORTCODE_NOT_FOUND); + } + } + + /** + * Delete all of Users ShortCodes + * + * @param uid User Uid + * @returns number of all deleted user ShortCodes + */ + async deleteUserShortCodes(uid: string) { + const deletedShortCodes = await this.prisma.shortcode.deleteMany({ + where: { + creatorUid: uid, + }, + }); + + return deletedShortCodes.count; + } }