From 2a05f15156b66b6d7ebb86fa4ab2d6c6a18b2abf Mon Sep 17 00:00:00 2001 From: Leo Giovanetti Date: Thu, 6 Oct 2022 17:40:41 -0300 Subject: [PATCH] Recurring booking implementation (#167) * Recurring booking implementation * Tweaks to expected texts in tests * Applied feedback Co-authored-by: zomars --- jest.config.ts | 61 --------- lib/validations/booking.ts | 1 + pages/api/bookings/_get.ts | 37 ++++++ pages/api/bookings/_post.ts | 168 +++++++++++++++++++++++++ pages/api/bookings/index.ts | 174 ++------------------------ test/README.md | 12 ++ test/docker-compose.yml | 15 +++ test/jest-resolver.js | 15 +++ test/jest-setup.js | 6 + test/lib/bookings/_post.test.ts | 215 ++++++++++++++++++++++++++++++++ 10 files changed, 476 insertions(+), 228 deletions(-) delete mode 100644 jest.config.ts create mode 100644 pages/api/bookings/_get.ts create mode 100644 pages/api/bookings/_post.ts create mode 100644 test/README.md create mode 100644 test/docker-compose.yml create mode 100644 test/jest-resolver.js create mode 100644 test/jest-setup.js create mode 100644 test/lib/bookings/_post.test.ts diff --git a/jest.config.ts b/jest.config.ts deleted file mode 100644 index 7923b4cacc..0000000000 --- a/jest.config.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * For a detailed explanation regarding each configuration property and type check, visit: - * https://jestjs.io/docs/en/configuration.html - */ - -const config = { - clearMocks: true, - coverageDirectory: "./coverage", - collectCoverage: true, - collectCoverageFrom: ["pages/api/**/*.ts"], - - // An array of regexp pattern strings used to skip coverage collection - // coveragePathIgnorePatterns: [ - // "/node_modules/" - // ], - - // Indicates which provider should be used to instrument code for coverage - // coverageProvider: "babel", - - // A list of reporter names that Jest uses when writing coverage reports - coverageReporters: ["json", "text", "lcov", "clover"], - - // An object that configures minimum threshold enforcement for coverage results - coverageThreshold: { - global: { - lines: 50, - functions: 40, - branches: 50, - statements: 50, - }, - }, - // A path to a custom dependency extractor - // dependencyExtractor: undefined, - - // Make calling deprecated APIs throw helpful error messages - errorOnDeprecated: true, - - // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. - maxWorkers: "50%", - - moduleNameMapper: { - "^@lib/(.*)$": "/lib/$1", - "^@api/(.*)$": "/pages/api/$1", - }, - - // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: [], - - // A list of paths to modules that run some code to configure or set up the testing framework before each test - setupFilesAfterEnv: ["/jest.setup.ts"], - - // The number of seconds after which a test is considered as slow and reported as such in the results. - slowTestThreshold: 0.1, - - // A list of paths to snapshot serializer modules Jest should use for snapshot testing - // snapshotSerializers: [], - - // The test environment that will be used for testing - testEnvironment: "node", -}; -export default config; diff --git a/lib/validations/booking.ts b/lib/validations/booking.ts index a7a8eab730..25e580059d 100644 --- a/lib/validations/booking.ts +++ b/lib/validations/booking.ts @@ -16,6 +16,7 @@ const schemaBookingCreateParams = z title: z.string(), startTime: z.date().or(z.string()), endTime: z.date().or(z.string()), + recurringCount: z.number().optional(), }) .strict(); diff --git a/pages/api/bookings/_get.ts b/pages/api/bookings/_get.ts new file mode 100644 index 0000000000..527af2718f --- /dev/null +++ b/pages/api/bookings/_get.ts @@ -0,0 +1,37 @@ +import { Prisma } from "@prisma/client"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; + +import { BookingResponse, BookingsResponse } from "@lib/types"; +import { schemaBookingReadPublic } from "@lib/validations/booking"; + +/** + * @swagger + * /bookings: + * get: + * summary: Find all bookings + * operationId: listBookings + * tags: + * - bookings + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No bookings were found + */ +async function handler( + { userId, isAdmin, prisma }: NextApiRequest, + res: NextApiResponse +) { + const args: Prisma.BookingFindManyArgs = isAdmin ? {} : { where: { userId } }; + const data = await prisma.booking.findMany(args); + const bookings = data.map((booking) => schemaBookingReadPublic.parse(booking)); + if (!bookings) throw new HttpError({ statusCode: 401, message: "No Bookings were found" }); + res.status(200).json({ bookings }); +} + +export default defaultResponder(handler); diff --git a/pages/api/bookings/_post.ts b/pages/api/bookings/_post.ts new file mode 100644 index 0000000000..2509c24540 --- /dev/null +++ b/pages/api/bookings/_post.ts @@ -0,0 +1,168 @@ +import { HttpError } from "@/../../packages/lib/http-error"; +import { WebhookTriggerEvents } from "@prisma/client"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 as uuidv4 } from "uuid"; +import z from "zod"; + +import { BookingResponse, BookingsResponse } from "@calcom/api/lib/types"; +import sendPayload from "@calcom/api/lib/utils/sendPayload"; +import getWebhooks from "@calcom/api/lib/utils/webhookSubscriptions"; +import { schemaBookingCreateBodyParams, schemaBookingReadPublic } from "@calcom/api/lib/validations/booking"; +import { schemaEventTypeReadPublic } from "@calcom/api/lib/validations/event-type"; +import { defaultResponder } from "@calcom/lib/server"; + +/** + * @swagger + * /bookings: + * post: + * summary: Creates a new booking + * operationId: addBooking + * requestBody: + * description: Edit an existing booking related to one of your event-types + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * title: + * type: string + * example: 15min + * startTime: + * type: string + * example: 1970-01-01T17:00:00.000Z + * endTime: + * type: string + * example: 1970-01-01T17:00:00.000Z + * recurringCount: + * type: number + * example: 8 + * tags: + * - bookings + * responses: + * 201: + * description: Booking(s) created successfully. + * 400: + * description: | + * Message | Cause + * :--|:-- + * Booking body is invalid| Missing property on booking entity. + * Invalid eventTypeId| The provided eventTypeId does not exist. + * Missing recurringCount| The eventType is recurring, and no recurringCount was passed. + * Invalid recurringCount| The provided recurringCount is greater than the eventType recurring config + * 401: + * description: Authorization information is missing or invalid. + */ +async function handler( + { body, userId, isAdmin, prisma }: NextApiRequest, + res: NextApiResponse +) { + const booking = schemaBookingCreateBodyParams.parse(body); + if (!isAdmin) { + booking.userId = userId; + } + const eventTypeDb = await prisma.eventType.findUnique({ + where: { id: booking.eventTypeId }, + }); + const eventType = schemaEventTypeReadPublic.parse(eventTypeDb); + let bookings: z.infer[]; + if (!eventType) throw new HttpError({ statusCode: 400, message: "Could not create new booking" }); + if (eventType.recurringEvent) { + console.log("Event type has recurring configuration"); + if (!booking.recurringCount) throw new HttpError({ statusCode: 400, message: "Missing recurringCount" }); + if (eventType.recurringEvent.count && booking.recurringCount > eventType?.recurringEvent.count) { + throw new HttpError({ statusCode: 400, message: "Invalid recurringCount" }); + } + // Event type is recurring, ceating each booking + const recurringEventId = uuidv4(); + const allBookings = await Promise.all( + Array.from(Array(booking.recurringCount).keys()).map(async () => { + return await prisma.booking.create({ + data: { + uid: uuidv4(), + recurringEventId, + eventTypeId: booking.eventTypeId, + title: booking.title, + startTime: booking.startTime, + endTime: booking.endTime, + userId: booking.userId, + }, + }); + }) + ); + bookings = allBookings.map((book) => schemaBookingReadPublic.parse(book)); + } else { + // Event type not recurring, ceating as single one + const data = await prisma.booking.create({ + data: { + uid: uuidv4(), + eventTypeId: booking.eventTypeId, + title: booking.title, + startTime: booking.startTime, + endTime: booking.endTime, + userId: booking.userId, + }, + }); + bookings = [schemaBookingReadPublic.parse(data)]; + } + + await Promise.all( + bookings.map(async (booking) => { + const evt = { + type: eventType?.title || booking.title, + title: booking.title, + description: "", + additionalNotes: "", + customInputs: {}, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + name: "", + email: "", + timeZone: "", + language: { + locale: "en", + }, + }, + attendees: [], + location: "", + destinationCalendar: null, + hideCalendar: false, + uid: booking.uid, + metadata: {}, + }; + console.log(`evt: ${evt}`); + + // Send Webhook call if hooked to BOOKING_CREATED + const triggerEvent = WebhookTriggerEvents.BOOKING_CREATED; + console.log(`Trigger Event: ${triggerEvent}`); + const subscriberOptions = { + userId, + eventTypeId: booking.eventTypeId as number, + triggerEvent, + }; + console.log(`subscriberOptions: ${subscriberOptions}`); + + const subscribers = await getWebhooks(subscriberOptions, prisma); + console.log(`subscribers: ${subscribers}`); + const bookingId = booking?.id; + await Promise.all( + subscribers.map((sub) => + sendPayload(triggerEvent, new Date().toISOString(), sub, { + ...evt, + bookingId, + }) + ) + ); + console.log("All promises resolved! About to send the response"); + }) + ); + + if (bookings.length > 1) { + res.status(201).json({ bookings, message: "Bookings created successfully." }); + } else { + res.status(201).json({ booking: bookings[0], message: "Booking created successfully." }); + } +} + +export default defaultResponder(handler); diff --git a/pages/api/bookings/index.ts b/pages/api/bookings/index.ts index b70e059d8b..c07846423f 100644 --- a/pages/api/bookings/index.ts +++ b/pages/api/bookings/index.ts @@ -1,170 +1,10 @@ -import { WebhookTriggerEvents } from "@prisma/client"; -import type { NextApiRequest, NextApiResponse } from "next"; -import { v4 as uuidv4 } from "uuid"; +import { defaultHandler } from "@calcom/lib/server"; import { withMiddleware } from "@lib/helpers/withMiddleware"; -import { BookingResponse, BookingsResponse } from "@lib/types"; -import sendPayload from "@lib/utils/sendPayload"; -import getWebhooks from "@lib/utils/webhookSubscriptions"; -import { schemaBookingCreateBodyParams, schemaBookingReadPublic } from "@lib/validations/booking"; -import { schemaEventTypeReadPublic } from "@lib/validations/event-type"; -async function createOrlistAllBookings( - { method, body, userId, isAdmin, prisma }: NextApiRequest, - res: NextApiResponse -) { - if (method === "GET") { - /** - * @swagger - * /bookings: - * get: - * summary: Find all bookings - * operationId: listBookings - * tags: - * - bookings - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No bookings were found - */ - if (!isAdmin) { - const data = await prisma.booking.findMany({ where: { userId } }); - const bookings = data.map((booking) => schemaBookingReadPublic.parse(booking)); - if (bookings) res.status(200).json({ bookings }); - else { - (error: Error) => - res.status(404).json({ - message: "No Bookings were found", - error, - }); - } - } else { - const data = await prisma.booking.findMany(); - const bookings = data.map((booking) => schemaBookingReadPublic.parse(booking)); - if (bookings) res.status(200).json({ bookings }); - else { - (error: Error) => - res.status(404).json({ - message: "No Bookings were found", - error, - }); - } - } - } else if (method === "POST") { - /** - * @swagger - * /bookings: - * post: - * summary: Creates a new booking - * operationId: addBooking - * requestBody: - * description: Edit an existing booking related to one of your event-types - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * title: - * type: string - * example: 15min - * startTime: - * type: string - * example: 1970-01-01T17:00:00.000Z - * endTime: - * type: string - * example: 1970-01-01T17:00:00.000Z - * tags: - * - bookings - * responses: - * 201: - * description: OK, booking created - * 400: - * description: Bad request. Booking body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - const safe = schemaBookingCreateBodyParams.safeParse(body); - if (!safe.success) { - console.log(safe.error); - res.status(400).json({ message: "Bad request. Booking body is invalid." }); - return; - } - if (!isAdmin) { - safe.data.userId = userId; - } - const data = await prisma.booking.create({ data: { uid: uuidv4(), ...safe.data } }); - const booking = schemaBookingReadPublic.parse(data); - - if (booking) { - const eventType = await prisma.eventType - .findUnique({ where: { id: booking.eventTypeId as number } }) - .then((data) => schemaEventTypeReadPublic.parse(data)) - .catch((e: Error) => { - console.error(`Event type with ID: ${booking.eventTypeId} not found`, e); - }); - console.log(`eventType: ${eventType}`); - const evt = { - type: eventType?.title || booking.title, - title: booking.title, - description: "", - additionalNotes: "", - customInputs: {}, - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - organizer: { - name: "", - email: "", - timeZone: "", - language: { - locale: "en", - }, - }, - attendees: [], - location: "", - destinationCalendar: null, - hideCalendar: false, - uid: booking.uid, - metadata: {}, - }; - console.log(`evt: ${evt}`); - - // Send Webhook call if hooked to BOOKING_CREATED - const triggerEvent = WebhookTriggerEvents.BOOKING_CREATED; - console.log(`Trigger Event: ${triggerEvent}`); - const subscriberOptions = { - userId, - eventTypeId: booking.eventTypeId as number, - triggerEvent, - }; - console.log(`subscriberOptions: ${subscriberOptions}`); - - const subscribers = await getWebhooks(subscriberOptions, prisma); - console.log(`subscribers: ${subscribers}`); - const bookingId = booking?.id; - const promises = subscribers.map((sub) => - sendPayload(triggerEvent, new Date().toISOString(), sub, { - ...evt, - bookingId, - }).catch((e) => { - console.error(`Error executing webhook for event: ${triggerEvent}, URL: ${sub.subscriberUrl}`, e); - }) - ); - await Promise.all(promises); - console.log("All promises resolved! About to send the response"); - res.status(201).json({ booking, message: "Booking created successfully" }); - } else - (error: Error) => { - console.log(error); - res.status(400).json({ - message: "Could not create new booking", - error, - }); - }; - } else res.status(405).json({ message: `Method ${method} not allowed` }); -} - -export default withMiddleware("HTTP_GET_OR_POST")(createOrlistAllBookings); +export default withMiddleware("HTTP_GET_OR_POST")( + defaultHandler({ + GET: import("./_get"), + POST: import("./_post"), + }) +); diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000000..938d54f0be --- /dev/null +++ b/test/README.md @@ -0,0 +1,12 @@ +# Unit and Integration Tests + +Make sure you have copied .env.test.example to .env.test + +You can run all jest tests as + +`yarn test` + +You can run tests matching specific description by following command +`yarn test -t _post` + +Tip: Use `--watchAll` flag to run tests on every change diff --git a/test/docker-compose.yml b/test/docker-compose.yml new file mode 100644 index 0000000000..769adab097 --- /dev/null +++ b/test/docker-compose.yml @@ -0,0 +1,15 @@ +# Set the version of docker compose to use +version: '3.9' + +# The containers that compose the project +services: + db: + image: postgres:13 + restart: always + container_name: integration-tests-prisma + ports: + - '5433:5432' + environment: + POSTGRES_USER: prisma + POSTGRES_PASSWORD: prisma + POSTGRES_DB: tests \ No newline at end of file diff --git a/test/jest-resolver.js b/test/jest-resolver.js new file mode 100644 index 0000000000..d5fb532a29 --- /dev/null +++ b/test/jest-resolver.js @@ -0,0 +1,15 @@ +module.exports = (path, options) => { + // Call the defaultResolver, so we leverage its cache, error handling, etc. + return options.defaultResolver(path, { + ...options, + // Use packageFilter to process parsed `package.json` before the resolution (see https://www.npmjs.com/package/resolve#resolveid-opts-cb) + packageFilter: (pkg) => { + // See https://github.com/microsoft/accessibility-insights-web/blob/40416a4ae6b91baf43102f58e069eff787de4de2/src/tests/common/resolver.js + if (pkg.name === "uuid" || pkg.name === "nanoid") { + delete pkg["exports"]; + delete pkg["module"]; + } + return pkg; + }, + }); +}; diff --git a/test/jest-setup.js b/test/jest-setup.js new file mode 100644 index 0000000000..fa5c44868d --- /dev/null +++ b/test/jest-setup.js @@ -0,0 +1,6 @@ +// This is a workaround for https://github.com/jsdom/jsdom/issues/2524#issuecomment-902027138 + +// See https://github.com/microsoft/accessibility-insights-web/blob/40416a4ae6b91baf43102f58e069eff787de4de2/src/tests/unit/jest-setup.ts +const { TextEncoder, TextDecoder } = require("util"); +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder; diff --git a/test/lib/bookings/_post.test.ts b/test/lib/bookings/_post.test.ts new file mode 100644 index 0000000000..5fc57e5c9d --- /dev/null +++ b/test/lib/bookings/_post.test.ts @@ -0,0 +1,215 @@ +import { Booking, WebhookTriggerEvents } from "@prisma/client"; +import { Request, Response } from "express"; +import { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; + +import sendPayload from "@calcom/api/lib/utils/sendPayload"; +import handler from "@calcom/api/pages/api/bookings/_post"; +import dayjs from "@calcom/dayjs"; +import { buildEventType, buildWebhook, buildBooking } from "@calcom/lib/test/builder"; +import prisma from "@calcom/prisma"; + +import { prismaMock } from "../../../../../tests/config/singleton"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; +jest.mock("@calcom/api/lib/utils/sendPayload"); + +describe("POST /api/bookings", () => { + describe("Errors", () => { + test("Missing required data", async () => { + const { req, res } = createMocks({ + method: "POST", + body: {}, + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + message: "Booking body is invalid.", + }) + ); + }); + + test("Invalid eventTypeId", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + }, + prisma, + }); + + //prismaMock.eventType.findUnique.mockResolvedValue(null); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + message: "Invalid eventTypeId.", + }) + ); + }); + + test("Missing recurringCount", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + }, + prisma, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) + ); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + message: "Missing recurringCount.", + }) + ); + }); + + test("Invalid recurringCount", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + recurringCount: 15, + }, + prisma, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) + ); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + message: "Invalid recurringCount.", + }) + ); + }); + }); + + describe("Success", () => { + describe("Regular event-type", () => { + test("Creates one single booking", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + }, + prisma, + }); + + prismaMock.eventType.findUnique.mockResolvedValue(buildEventType()); + + await handler(req, res); + + expect(prismaMock.booking.create).toHaveBeenCalledTimes(1); + }); + }); + + describe("Recurring event-type", () => { + test("Creates multiple bookings", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + recurringCount: 12, + }, + prisma, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) + ); + + Array.from(Array(12).keys()).map(async () => { + prismaMock.booking.create.mockResolvedValue(buildBooking()); + }); + + prismaMock.webhook.findMany.mockResolvedValue([]); + + await handler(req, res); + const data = JSON.parse(res._getData()); + + expect(prismaMock.booking.create).toHaveBeenCalledTimes(12); + expect(res._getStatusCode()).toBe(201); + expect(data.message).toEqual("Bookings created successfully."); + expect(data.bookings.length).toEqual(12); + }); + }); + test("Notifies multiple bookings", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + recurringCount: 12, + }, + prisma, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) + ); + + const createdAt = new Date(); + Array.from(Array(12).keys()).map(async () => { + prismaMock.booking.create.mockResolvedValue(buildBooking({ createdAt })); + }); + + const mockedWebhooks = [ + buildWebhook({ + subscriberUrl: "http://mockedURL1.com", + createdAt, + eventTypeId: 1, + secret: "secret1", + }), + buildWebhook({ + subscriberUrl: "http://mockedURL2.com", + createdAt, + eventTypeId: 2, + secret: "secret2", + }), + ]; + prismaMock.webhook.findMany.mockResolvedValue(mockedWebhooks); + + await handler(req, res); + const data = JSON.parse(res._getData()); + + expect(sendPayload).toHaveBeenCalledTimes(24); + expect(data.message).toEqual("Bookings created successfully."); + expect(data.bookings.length).toEqual(12); + }); + }); +});