add e2e testing on webhooks and booking happy-path (#936)

This commit is contained in:
Alex Johansson 2021-10-18 23:07:06 +02:00 committed by GitHub
parent 86d292838c
commit 9e69029943
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 271 additions and 80 deletions

View File

@ -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"
}
}
],

View File

@ -90,7 +90,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
return (
<div key={slot.time.format()}>
<Link href={bookingUrl}>
<a className="block font-medium mb-2 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-primary-500 dark:border-transparent rounded-sm hover:text-white hover:bg-primary-500 dark:hover:border-black py-4 dark:hover:bg-black">
<a
className="block font-medium mb-2 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-primary-500 dark:border-transparent rounded-sm hover:text-white hover:bg-primary-500 dark:hover:border-black py-4 dark:hover:bg-black"
data-testid="time">
{slot.time.format(timeFormat)}
</a>
</Link>

View File

@ -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 = ({
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
<button
onClick={decrementMonth}
className={
"group mr-2 p-1" + (selectedMonth <= dayjs().month() && "text-gray-400 dark:text-gray-600")
}
disabled={selectedMonth <= dayjs().month()}>
className={classNames(
"group mr-2 p-1",
typeof selectedMonth === "number" &&
selectedMonth <= dayjs().month() &&
"text-gray-400 dark:text-gray-600"
)}
disabled={typeof selectedMonth === "number" && selectedMonth <= dayjs().month()}>
<ChevronLeftIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
</button>
<button className="group p-1" onClick={incrementMonth}>
@ -190,7 +195,9 @@ const DatePicker = ({
: !day.disabled
? " bg-gray-100 dark:bg-gray-600"
: ""
)}>
)}
data-testid="day"
data-disabled={day.disabled}>
{day.date}
</button>
)}
@ -199,6 +206,6 @@ const DatePicker = ({
</div>
</div>
);
};
}
export default DatePicker;

View File

@ -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: ["<rootDir>/playwright/**/?(*.)+(spec|test).[jt]s?(x)"],
testMatch: ["<rootDir>/playwright/**/*(*.)@(spec|test).[jt]s?(x)"],
testEnvironmentOptions: {
"jest-playwright": {
browsers: ["chromium" /*, 'firefox', 'webkit'*/],

View File

@ -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;
}

View File

@ -8,21 +8,26 @@ const sendPayload = (
): Promise<string | Response> =>
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) => {

View File

@ -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",

View File

@ -25,7 +25,12 @@ type AugmentedNextPageContext = Omit<NextPageContext, "err"> & {
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;
}

View File

@ -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) => ({

View File

@ -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,

View File

@ -138,6 +138,7 @@ function WebhookDialogForm(props: {
});
return (
<Form
data-testid="WebhookDialogForm"
form={form}
onSubmit={(event) => {
form
@ -248,7 +249,7 @@ function WebhookEmbed(props: { webhooks: TWebhook[] }) {
<ListItemText component="p">Automation</ListItemText>
</div>
<div>
<Button color="secondary" onClick={() => setNewWebhookModal(true)}>
<Button color="secondary" onClick={() => setNewWebhookModal(true)} data-testid="new_webhook">
{t("new_webhook")}
</Button>
</div>

View File

@ -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<typeof getServerSideProps>)
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<typeof getServerSideProps>)
.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<typeof getServerSideProps>)
return (
(isReady && (
<div className="h-screen bg-neutral-50 dark:bg-neutral-900">
<div className="h-screen bg-neutral-50 dark:bg-neutral-900" data-testid="success-page">
<HeadSeo
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
description={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
@ -306,21 +307,22 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
notFound: true,
};
}
if (!eventType.users.length && eventType.userId) {
eventType.users.push(
await prisma.user.findUnique({
where: {
id: eventType.userId,
},
select: {
theme: true,
hideBranding: true,
name: true,
plan: true,
},
})
);
// TODO we should add `user User` relation on `EventType` so this extra query isn't needed
const user = await prisma.user.findUnique({
where: {
id: eventType.userId,
},
select: {
name: true,
hideBranding: true,
plan: true,
theme: true,
},
});
if (user) {
eventType.users.push(user);
}
}
if (!eventType.users.length) {

View File

@ -0,0 +1,99 @@
import dayjs from "dayjs";
import { kont } from "kont";
import { loginProvider } from "./lib/loginProvider";
import { createHttpServer, waitFor } from "./lib/testUtils";
jest.setTimeout(60e3);
jest.retryTimes(3);
describe("webhooks", () => {
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();
});
});

View File

@ -37,7 +37,7 @@ export function loginProvider(opts: {
waitForSelector?: string;
}): Provider<Needs, Contributes> {
return provider<Needs, Contributes>()
.name("page")
.name("login")
.before(async () => {
const context = await browser.newContext();
const page = await context.newPage();

View File

@ -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> | 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));
}
}
}

View File

@ -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"