diff --git a/.eslintrc.json b/.eslintrc.json index f196661f..f27a7d1d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -28,7 +28,9 @@ "files": ["playwright/**/*.{js,jsx,tsx,ts}"], "rules": { "no-undef": "off", - "@typescript-eslint/no-non-null-assertion": "off" + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-implicit-any": "off" } } ], diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx index e4ea2008..ba3b882b 100644 --- a/components/booking/AvailableTimes.tsx +++ b/components/booking/AvailableTimes.tsx @@ -90,7 +90,9 @@ const AvailableTimes: FC = ({ return (
- + {slot.time.format(timeFormat)} diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index 58ec01c0..f3ce4894 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -1,6 +1,7 @@ import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; import dayjs, { Dayjs } from "dayjs"; -import dayjsBusinessDays from "dayjs-business-days"; +// Then, include dayjs-business-time +import dayjsBusinessTime from "dayjs-business-time"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { useEffect, useState } from "react"; @@ -9,11 +10,12 @@ import classNames from "@lib/classNames"; import { useLocale } from "@lib/hooks/useLocale"; import getSlots from "@lib/slots"; -dayjs.extend(dayjsBusinessDays); +dayjs.extend(dayjsBusinessTime); dayjs.extend(utc); dayjs.extend(timezone); -const DatePicker = ({ +// FIXME prop types +function DatePicker({ weekStart, onDatePicked, workingHours, @@ -26,7 +28,7 @@ const DatePicker = ({ periodDays, periodCountCalendarDays, minimumBookingNotice, -}) => { +}: any): JSX.Element { const { t } = useLocale(); const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]); @@ -47,11 +49,11 @@ const DatePicker = ({ // Handle month changes const incrementMonth = () => { - setSelectedMonth(selectedMonth + 1); + setSelectedMonth((selectedMonth ?? 0) + 1); }; const decrementMonth = () => { - setSelectedMonth(selectedMonth - 1); + setSelectedMonth((selectedMonth ?? 0) - 1); }; const inviteeDate = (): Dayjs => (date || dayjs()).month(selectedMonth); @@ -72,7 +74,7 @@ const DatePicker = ({ case "rolling": { const periodRollingEndDay = periodCountCalendarDays ? dayjs().tz(organizerTimeZone).add(periodDays, "days").endOf("day") - : dayjs().tz(organizerTimeZone).businessDaysAdd(periodDays, "days").endOf("day"); + : dayjs().tz(organizerTimeZone).addBusinessTime(periodDays, "days").endOf("day"); return ( date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) || date.endOf("day").isAfter(periodRollingEndDay) || @@ -145,10 +147,13 @@ const DatePicker = ({
)} @@ -199,6 +206,6 @@ const DatePicker = ({
); -}; +} export default DatePicker; diff --git a/jest.playwright.config.js b/jest.playwright.config.js index 0962dba0..509a4e6f 100644 --- a/jest.playwright.config.js +++ b/jest.playwright.config.js @@ -5,7 +5,7 @@ const opts = { executablePath: process.env.PLAYWRIGHT_CHROME_EXECUTABLE_PATH, }; -console.log("⚙️ Playwright options:", opts); +console.log("⚙️ Playwright options:", JSON.stringify(opts, null, 4)); module.exports = { verbose: true, @@ -13,7 +13,7 @@ module.exports = { transform: { "^.+\\.ts$": "ts-jest", }, - testMatch: ["/playwright/**/?(*.)+(spec|test).[jt]s?(x)"], + testMatch: ["/playwright/**/*(*.)@(spec|test).[jt]s?(x)"], testEnvironmentOptions: { "jest-playwright": { browsers: ["chromium" /*, 'firefox', 'webkit'*/], diff --git a/lib/asStringOrNull.tsx b/lib/asStringOrNull.tsx index 0f41a8fd..2d7bb94b 100644 --- a/lib/asStringOrNull.tsx +++ b/lib/asStringOrNull.tsx @@ -15,9 +15,8 @@ export function asNumberOrThrow(str: unknown) { } export function asStringOrThrow(str: unknown): string { - const type = typeof str; - if (type !== "string") { - throw new Error(`Expected "string" - got ${type}`); + if (typeof str !== "string") { + throw new Error(`Expected "string" - got ${typeof str}`); } return str; } diff --git a/lib/webhooks/sendPayload.tsx b/lib/webhooks/sendPayload.tsx index 9a64b9b5..fc95dffd 100644 --- a/lib/webhooks/sendPayload.tsx +++ b/lib/webhooks/sendPayload.tsx @@ -8,21 +8,26 @@ const sendPayload = ( ): Promise => new Promise((resolve, reject) => { if (!subscriberUrl || !payload) { - return reject("Missing required elements to send webhook payload."); + return reject(new Error("Missing required elements to send webhook payload.")); } + const body = { + triggerEvent: triggerEvent, + createdAt: createdAt, + payload: payload, + }; fetch(subscriberUrl, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ - triggerEvent: triggerEvent, - createdAt: createdAt, - payload: payload, - }), + body: JSON.stringify(body), }) .then((response) => { + if (!response.ok) { + reject(new Error(`Response code ${response.status}`)); + return; + } resolve(response); }) .catch((err) => { diff --git a/package.json b/package.json index 19c189eb..5fe09492 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test": "jest", "test-playwright": "jest --config jest.playwright.config.js", "test-playwright-lcov": "cross-env PLAYWRIGHT_HEADLESS=1 PLAYWRIGHT_COVERAGE=1 yarn test-playwright && nyc report --reporter=lcov", + "test-codegen": "yarn playwright codegen http://localhost:3000", "type-check": "tsc --pretty --noEmit", "build": "next build", "start": "next start", @@ -58,7 +59,7 @@ "bcryptjs": "^2.4.3", "classnames": "^2.3.1", "dayjs": "^1.10.6", - "dayjs-business-days": "^1.0.4", + "dayjs-business-time": "^1.0.4", "googleapis": "^84.0.0", "handlebars": "^4.7.7", "ical.js": "^1.4.0", @@ -103,7 +104,7 @@ "@types/jest": "^27.0.1", "@types/lodash": "^4.14.175", "@types/micro": "^7.3.6", - "@types/node": "^16.6.1", + "@types/node": "^16.10.2", "@types/nodemailer": "^6.4.4", "@types/qrcode": "^1.4.1", "@types/react": "^17.0.18", diff --git a/pages/_error.tsx b/pages/_error.tsx index b33da821..b80c9074 100644 --- a/pages/_error.tsx +++ b/pages/_error.tsx @@ -25,7 +25,12 @@ type AugmentedNextPageContext = Omit & { const log = logger.getChildLogger({ prefix: ["[error]"] }); -export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number } { +export function getErrorFromUnknown(cause: unknown): Error & { + // status code error + statusCode?: number; + // prisma error + code?: unknown; +} { if (cause instanceof Error) { return cause; } diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index a95d2ba7..58d9f8fe 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -1,11 +1,12 @@ -import { SchedulingType, Prisma, Credential } from "@prisma/client"; +import { Credential, Prisma, SchedulingType } from "@prisma/client"; import async from "async"; import dayjs from "dayjs"; -import dayjsBusinessDays from "dayjs-business-days"; +import dayjsBusinessTime from "dayjs-business-time"; import isBetween from "dayjs/plugin/isBetween"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import type { NextApiRequest, NextApiResponse } from "next"; +import { getErrorFromUnknown } from "pages/_error"; import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; @@ -29,7 +30,7 @@ export interface DailyReturnType { created_at: string; } -dayjs.extend(dayjsBusinessDays); +dayjs.extend(dayjsBusinessTime); dayjs.extend(utc); dayjs.extend(isBetween); dayjs.extend(timezone); @@ -98,7 +99,7 @@ function isAvailable(busyTimes: BufferedBusyTimes, time: string, length: number) function isOutOfBounds( time: dayjs.ConfigType, - { periodType, periodDays, periodCountCalendarDays, periodStartDate, periodEndDate, timeZone } + { periodType, periodDays, periodCountCalendarDays, periodStartDate, periodEndDate, timeZone }: any // FIXME types ): boolean { const date = dayjs(time); @@ -106,7 +107,7 @@ function isOutOfBounds( case "rolling": { const periodRollingEndDay = periodCountCalendarDays ? dayjs().tz(timeZone).add(periodDays, "days").endOf("day") - : dayjs().tz(timeZone).businessDaysAdd(periodDays, "days").endOf("day"); + : dayjs().tz(timeZone).addBusinessTime(periodDays, "days").endOf("day"); return date.endOf("day").isAfter(periodRollingEndDay); } @@ -298,7 +299,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) startTime: dayjs(evt.startTime).toDate(), endTime: dayjs(evt.endTime).toDate(), description: evt.description, - confirmed: !eventType.requiresConfirmation || !!rescheduleUid, + confirmed: !eventType?.requiresConfirmation || !!rescheduleUid, location: evt.location, eventType: { connect: { @@ -323,9 +324,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) let booking: Booking | null = null; try { booking = await createBooking(); - } catch (e) { - log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", e.message); - if (e.code === "P2002") { + } catch (_err) { + const err = getErrorFromUnknown(_err); + log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message); + if (err.code === "P2002") { res.status(409).json({ message: "booking.conflict" }); return; } @@ -361,7 +363,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ); const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter((time) => time); - calendarBusyTimes.push(...videoBusyTimes); + calendarBusyTimes.push(...(videoBusyTimes as any[])); // FIXME add types console.log("calendarBusyTimes==>>>", calendarBusyTimes); const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({ diff --git a/pages/api/webhooks/[hook]/index.ts b/pages/api/webhooks/[hook]/index.ts index 02861ac2..0b09c8e6 100644 --- a/pages/api/webhooks/[hook]/index.ts +++ b/pages/api/webhooks/[hook]/index.ts @@ -1,23 +1,27 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { getSession } from "next-auth/client"; +import { getSession } from "@lib/auth"; import prisma from "@lib/prisma"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({ req: req }); - if (!session) { + const userId = session?.user?.id; + if (!userId) { return res.status(401).json({ message: "Not authenticated" }); } // GET /api/webhook/{hook} - const webhooks = await prisma.webhook.findFirst({ + const webhook = await prisma.webhook.findFirst({ where: { id: String(req.query.hook), - userId: session.user.id, + userId, }, }); + if (!webhook) { + return res.status(404).json({ message: "Invalid Webhook" }); + } if (req.method === "GET") { - return res.status(200).json({ webhooks: webhooks }); + return res.status(200).json({ webhook }); } // DELETE /api/webhook/{hook} @@ -31,19 +35,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (req.method === "PATCH") { - const webhook = await prisma.webhook.findUnique({ - where: { - id: req.query.hook as string, - }, - }); - - if (!webhook) { - return res.status(404).json({ message: "Invalid Webhook" }); - } - await prisma.webhook.update({ where: { - id: req.query.hook as string, + id: webhook.id, }, data: { subscriberUrl: req.body.subscriberUrl, diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index ffdd113b..6acccad4 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -138,6 +138,7 @@ function WebhookDialogForm(props: { }); return (
{ form @@ -248,7 +249,7 @@ function WebhookEmbed(props: { webhooks: TWebhook[] }) { Automation
-
diff --git a/pages/success.tsx b/pages/success.tsx index 13237d53..c8d70f45 100644 --- a/pages/success.tsx +++ b/pages/success.tsx @@ -5,6 +5,7 @@ import timezone from "dayjs/plugin/timezone"; import toArray from "dayjs/plugin/toArray"; import utc from "dayjs/plugin/utc"; import { createEvent } from "ics"; +import { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; @@ -41,9 +42,9 @@ export default function Success(props: inferSSRProps) const eventName = getEventName(name, props.eventType.title, props.eventType.eventName); function eventLink(): string { - const optional: { location?: string | string[] } = {}; + const optional: { location?: string } = {}; if (location) { - optional["location"] = location; + optional["location"] = Array.isArray(location) ? location[0] : location; } const event = createEvent({ @@ -51,7 +52,7 @@ export default function Success(props: inferSSRProps) .utc() .toArray() .slice(0, 6) - .map((v, i) => (i === 1 ? v + 1 : v)), + .map((v, i) => (i === 1 ? v + 1 : v)) as any, // <-- FIXME fix types, not sure what's going on here startInputType: "utc", title: eventName, description: props.eventType.description ? props.eventType.description : undefined, @@ -71,7 +72,7 @@ export default function Success(props: inferSSRProps) return ( (isReady && ( -
+
{ + const ctx = kont() + .useBeforeEach( + loginProvider({ + user: "pro", + path: "/integrations", + waitForSelector: '[data-testid="new_webhook"]', + }) + ) + .done(); + + test("add webhook & test that creating an event triggers a webhook call", async () => { + const { page } = ctx; + const webhookReceiver = createHttpServer(); + + // --- add webhook + await page.click('[data-testid="new_webhook"]'); + await expect(page).toHaveSelector("[data-testid='WebhookDialogForm']"); + + await page.fill('[name="subscriberUrl"]', webhookReceiver.url); + + await page.click("[type=submit]"); + + // dialog is closed + await expect(page).not.toHaveSelector("[data-testid='WebhookDialogForm']"); + // page contains the url + await expect(page).toHaveSelector(`text='${webhookReceiver.url}'`); + + // --- go to tomorrow in the pro user's "30min"-event + const tomorrow = dayjs().add(1, "day"); + const tomorrowFormatted = tomorrow.format("YYYY-MM-DDZZ"); + + await page.goto(`http://localhost:3000/pro/30min?date=${encodeURIComponent(tomorrowFormatted)}`); + + // click first time available + await page.click("[data-testid=time]"); + + // --- fill form + await page.fill('[name="name"]', "Test Testson"); + await page.fill('[name="email"]', "test@example.com"); + await page.press('[name="email"]', "Enter"); + + // --- check that webhook was called + await waitFor(() => { + expect(webhookReceiver.requestList.length).toBe(1); + }); + + const [request] = webhookReceiver.requestList; + const body = request.body as any; + + // remove dynamic properties that differs depending on where you run the tests + const dynamic = "[redacted/dynamic]"; + body.createdAt = dynamic; + body.payload.startTime = dynamic; + body.payload.endTime = dynamic; + for (const attendee of body.payload.attendees) { + attendee.timeZone = dynamic; + } + body.payload.organizer.timeZone = dynamic; + + // if we change the shape of our webhooks, we can simply update this by clicking `u` + expect(body).toMatchInlineSnapshot(` + Object { + "createdAt": "[redacted/dynamic]", + "payload": Object { + "attendees": Array [ + Object { + "email": "test@example.com", + "name": "Test Testson", + "timeZone": "[redacted/dynamic]", + }, + ], + "description": "", + "endTime": "[redacted/dynamic]", + "organizer": Object { + "email": "pro@example.com", + "name": "Pro Example", + "timeZone": "[redacted/dynamic]", + }, + "startTime": "[redacted/dynamic]", + "title": "30min with Test Testson", + "type": "30min", + }, + "triggerEvent": "BOOKING_CREATED", + } + `); + + webhookReceiver.close(); + }); +}); diff --git a/playwright/lib/loginProvider.ts b/playwright/lib/loginProvider.ts index 63a5c5b2..eff6e41e 100644 --- a/playwright/lib/loginProvider.ts +++ b/playwright/lib/loginProvider.ts @@ -37,7 +37,7 @@ export function loginProvider(opts: { waitForSelector?: string; }): Provider { return provider() - .name("page") + .name("login") .before(async () => { const context = await browser.newContext(); const page = await context.newPage(); diff --git a/playwright/lib/testUtils.ts b/playwright/lib/testUtils.ts index 7a97d089..cc253b2e 100644 --- a/playwright/lib/testUtils.ts +++ b/playwright/lib/testUtils.ts @@ -1,4 +1,6 @@ -export function randomString(length: number) { +import { createServer, IncomingMessage, ServerResponse } from "http"; + +export function randomString(length = 12) { let result = ""; const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const charactersLength = characters.length; @@ -7,3 +9,65 @@ export function randomString(length: number) { } return result; } + +type Request = IncomingMessage & { body?: unknown }; +type RequestHandlerOptions = { req: Request; res: ServerResponse }; +type RequestHandler = (opts: RequestHandlerOptions) => void; + +export function createHttpServer(opts: { requestHandler?: RequestHandler } = {}) { + const { + requestHandler = ({ res }) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.write(JSON.stringify({})); + res.end(); + }, + } = opts; + const requestList: Request[] = []; + const server = createServer((req, res) => { + const buffer: unknown[] = []; + + req.on("data", (data) => { + buffer.push(data); + }); + req.on("end", () => { + const _req: Request = req; + // assume all incoming request bodies are json + const json = buffer.length ? JSON.parse(buffer.join("")) : undefined; + + _req.body = json; + requestList.push(_req); + requestHandler({ req: _req, res }); + }); + }); + + // listen on random port + server.listen(0); + const port: number = (server.address() as any).port; + const url = `http://localhost:${port}`; + return { + port, + close: () => server.close(), + requestList, + url, + }; +} + +/** + * When in need to wait for any period of time you can use waitFor, to wait for your expectations to pass. + */ +export async function waitFor(fn: () => Promise | unknown, opts: { timeout?: number } = {}) { + let finished = false; + const timeout = opts.timeout ?? 5000; // 5s + const timeStart = Date.now(); + while (!finished) { + try { + await fn(); + finished = true; + } catch { + if (Date.now() - timeStart >= timeout) { + throw new Error("waitFor timed out"); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } +} diff --git a/yarn.lock b/yarn.lock index db68479b..5987d9ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1779,10 +1779,15 @@ "@types/node" "*" "@types/socket.io" "2.1.13" -"@types/node@*", "@types/node@>=8.1.0", "@types/node@^16.6.1": +"@types/node@*", "@types/node@>=8.1.0": version "16.10.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.10.1.tgz#f3647623199ca920960006b3dccf633ea905f243" +"@types/node@^16.10.2": + version "16.10.2" + resolved "https://registry.npmjs.org/@types/node/-/node-16.10.2.tgz#5764ca9aa94470adb4e1185fe2e9f19458992b2e" + integrity sha512-zCclL4/rx+W5SQTzFs9wyvvyCwoK9QtBpratqz2IYJ3O8Umrn0m3nsTv0wQBk9sRGpvUe9CwPDrQFB10f1FIjQ== + "@types/nodemailer@^6.4.4": version "6.4.4" resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.4.tgz#c265f7e7a51df587597b3a49a023acaf0c741f4b" @@ -3036,11 +3041,14 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" -dayjs-business-days@^1.0.4: +dayjs-business-time@^1.0.4: version "1.0.4" - resolved "https://registry.yarnpkg.com/dayjs-business-days/-/dayjs-business-days-1.0.4.tgz#36e93e7566149e175c1541d92ce16e12145412bf" + resolved "https://registry.npmjs.org/dayjs-business-time/-/dayjs-business-time-1.0.4.tgz#2970b80e832e92bbaa27a06ea62772b0d970b75b" + integrity sha512-v/0ynVV0Ih9Qw/pqJdScVHfoIaHkxLSom8j9+jO+VUOPnxC0fj5QGpDAZ94LUFd7jBkq2UO8C1LrVY+EHFx3aA== + dependencies: + dayjs "^1.10.4" -dayjs@^1.10.6: +dayjs@^1.10.4, dayjs@^1.10.6: version "1.10.7" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"