add e2e testing on webhooks and booking happy-path (#936)
This commit is contained in:
parent
86d292838c
commit
9e69029943
16 changed files with 271 additions and 80 deletions
|
@ -28,7 +28,9 @@
|
||||||
"files": ["playwright/**/*.{js,jsx,tsx,ts}"],
|
"files": ["playwright/**/*.{js,jsx,tsx,ts}"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-undef": "off",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
@ -90,7 +90,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
return (
|
return (
|
||||||
<div key={slot.time.format()}>
|
<div key={slot.time.format()}>
|
||||||
<Link href={bookingUrl}>
|
<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)}
|
{slot.time.format(timeFormat)}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
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 timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
@ -9,11 +10,12 @@ import classNames from "@lib/classNames";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import getSlots from "@lib/slots";
|
import getSlots from "@lib/slots";
|
||||||
|
|
||||||
dayjs.extend(dayjsBusinessDays);
|
dayjs.extend(dayjsBusinessTime);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
const DatePicker = ({
|
// FIXME prop types
|
||||||
|
function DatePicker({
|
||||||
weekStart,
|
weekStart,
|
||||||
onDatePicked,
|
onDatePicked,
|
||||||
workingHours,
|
workingHours,
|
||||||
|
@ -26,7 +28,7 @@ const DatePicker = ({
|
||||||
periodDays,
|
periodDays,
|
||||||
periodCountCalendarDays,
|
periodCountCalendarDays,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
}) => {
|
}: any): JSX.Element {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
|
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
|
||||||
|
|
||||||
|
@ -47,11 +49,11 @@ const DatePicker = ({
|
||||||
|
|
||||||
// Handle month changes
|
// Handle month changes
|
||||||
const incrementMonth = () => {
|
const incrementMonth = () => {
|
||||||
setSelectedMonth(selectedMonth + 1);
|
setSelectedMonth((selectedMonth ?? 0) + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const decrementMonth = () => {
|
const decrementMonth = () => {
|
||||||
setSelectedMonth(selectedMonth - 1);
|
setSelectedMonth((selectedMonth ?? 0) - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const inviteeDate = (): Dayjs => (date || dayjs()).month(selectedMonth);
|
const inviteeDate = (): Dayjs => (date || dayjs()).month(selectedMonth);
|
||||||
|
@ -72,7 +74,7 @@ const DatePicker = ({
|
||||||
case "rolling": {
|
case "rolling": {
|
||||||
const periodRollingEndDay = periodCountCalendarDays
|
const periodRollingEndDay = periodCountCalendarDays
|
||||||
? dayjs().tz(organizerTimeZone).add(periodDays, "days").endOf("day")
|
? 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 (
|
return (
|
||||||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
||||||
date.endOf("day").isAfter(periodRollingEndDay) ||
|
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">
|
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
|
||||||
<button
|
<button
|
||||||
onClick={decrementMonth}
|
onClick={decrementMonth}
|
||||||
className={
|
className={classNames(
|
||||||
"group mr-2 p-1" + (selectedMonth <= dayjs().month() && "text-gray-400 dark:text-gray-600")
|
"group mr-2 p-1",
|
||||||
}
|
typeof selectedMonth === "number" &&
|
||||||
disabled={selectedMonth <= dayjs().month()}>
|
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" />
|
<ChevronLeftIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button className="group p-1" onClick={incrementMonth}>
|
<button className="group p-1" onClick={incrementMonth}>
|
||||||
|
@ -190,7 +195,9 @@ const DatePicker = ({
|
||||||
: !day.disabled
|
: !day.disabled
|
||||||
? " bg-gray-100 dark:bg-gray-600"
|
? " bg-gray-100 dark:bg-gray-600"
|
||||||
: ""
|
: ""
|
||||||
)}>
|
)}
|
||||||
|
data-testid="day"
|
||||||
|
data-disabled={day.disabled}>
|
||||||
{day.date}
|
{day.date}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
@ -199,6 +206,6 @@ const DatePicker = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default DatePicker;
|
export default DatePicker;
|
||||||
|
|
|
@ -5,7 +5,7 @@ const opts = {
|
||||||
executablePath: process.env.PLAYWRIGHT_CHROME_EXECUTABLE_PATH,
|
executablePath: process.env.PLAYWRIGHT_CHROME_EXECUTABLE_PATH,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("⚙️ Playwright options:", opts);
|
console.log("⚙️ Playwright options:", JSON.stringify(opts, null, 4));
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
@ -13,7 +13,7 @@ module.exports = {
|
||||||
transform: {
|
transform: {
|
||||||
"^.+\\.ts$": "ts-jest",
|
"^.+\\.ts$": "ts-jest",
|
||||||
},
|
},
|
||||||
testMatch: ["<rootDir>/playwright/**/?(*.)+(spec|test).[jt]s?(x)"],
|
testMatch: ["<rootDir>/playwright/**/*(*.)@(spec|test).[jt]s?(x)"],
|
||||||
testEnvironmentOptions: {
|
testEnvironmentOptions: {
|
||||||
"jest-playwright": {
|
"jest-playwright": {
|
||||||
browsers: ["chromium" /*, 'firefox', 'webkit'*/],
|
browsers: ["chromium" /*, 'firefox', 'webkit'*/],
|
||||||
|
|
|
@ -15,9 +15,8 @@ export function asNumberOrThrow(str: unknown) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asStringOrThrow(str: unknown): string {
|
export function asStringOrThrow(str: unknown): string {
|
||||||
const type = typeof str;
|
if (typeof str !== "string") {
|
||||||
if (type !== "string") {
|
throw new Error(`Expected "string" - got ${typeof str}`);
|
||||||
throw new Error(`Expected "string" - got ${type}`);
|
|
||||||
}
|
}
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,21 +8,26 @@ const sendPayload = (
|
||||||
): Promise<string | Response> =>
|
): Promise<string | Response> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
if (!subscriberUrl || !payload) {
|
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, {
|
fetch(subscriberUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
triggerEvent: triggerEvent,
|
|
||||||
createdAt: createdAt,
|
|
||||||
payload: payload,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
reject(new Error(`Response code ${response.status}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolve(response);
|
resolve(response);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test-playwright": "jest --config jest.playwright.config.js",
|
"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-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",
|
"type-check": "tsc --pretty --noEmit",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
@ -58,7 +59,7 @@
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"dayjs": "^1.10.6",
|
"dayjs": "^1.10.6",
|
||||||
"dayjs-business-days": "^1.0.4",
|
"dayjs-business-time": "^1.0.4",
|
||||||
"googleapis": "^84.0.0",
|
"googleapis": "^84.0.0",
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
"ical.js": "^1.4.0",
|
"ical.js": "^1.4.0",
|
||||||
|
@ -103,7 +104,7 @@
|
||||||
"@types/jest": "^27.0.1",
|
"@types/jest": "^27.0.1",
|
||||||
"@types/lodash": "^4.14.175",
|
"@types/lodash": "^4.14.175",
|
||||||
"@types/micro": "^7.3.6",
|
"@types/micro": "^7.3.6",
|
||||||
"@types/node": "^16.6.1",
|
"@types/node": "^16.10.2",
|
||||||
"@types/nodemailer": "^6.4.4",
|
"@types/nodemailer": "^6.4.4",
|
||||||
"@types/qrcode": "^1.4.1",
|
"@types/qrcode": "^1.4.1",
|
||||||
"@types/react": "^17.0.18",
|
"@types/react": "^17.0.18",
|
||||||
|
|
|
@ -25,7 +25,12 @@ type AugmentedNextPageContext = Omit<NextPageContext, "err"> & {
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[error]"] });
|
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) {
|
if (cause instanceof Error) {
|
||||||
return cause;
|
return cause;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { SchedulingType, Prisma, Credential } from "@prisma/client";
|
import { Credential, Prisma, SchedulingType } from "@prisma/client";
|
||||||
import async from "async";
|
import async from "async";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import dayjsBusinessDays from "dayjs-business-days";
|
import dayjsBusinessTime from "dayjs-business-time";
|
||||||
import isBetween from "dayjs/plugin/isBetween";
|
import isBetween from "dayjs/plugin/isBetween";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { getErrorFromUnknown } from "pages/_error";
|
||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
|
@ -29,7 +30,7 @@ export interface DailyReturnType {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
dayjs.extend(dayjsBusinessDays);
|
dayjs.extend(dayjsBusinessTime);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(isBetween);
|
dayjs.extend(isBetween);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
@ -98,7 +99,7 @@ function isAvailable(busyTimes: BufferedBusyTimes, time: string, length: number)
|
||||||
|
|
||||||
function isOutOfBounds(
|
function isOutOfBounds(
|
||||||
time: dayjs.ConfigType,
|
time: dayjs.ConfigType,
|
||||||
{ periodType, periodDays, periodCountCalendarDays, periodStartDate, periodEndDate, timeZone }
|
{ periodType, periodDays, periodCountCalendarDays, periodStartDate, periodEndDate, timeZone }: any // FIXME types
|
||||||
): boolean {
|
): boolean {
|
||||||
const date = dayjs(time);
|
const date = dayjs(time);
|
||||||
|
|
||||||
|
@ -106,7 +107,7 @@ function isOutOfBounds(
|
||||||
case "rolling": {
|
case "rolling": {
|
||||||
const periodRollingEndDay = periodCountCalendarDays
|
const periodRollingEndDay = periodCountCalendarDays
|
||||||
? dayjs().tz(timeZone).add(periodDays, "days").endOf("day")
|
? 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);
|
return date.endOf("day").isAfter(periodRollingEndDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -298,7 +299,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
startTime: dayjs(evt.startTime).toDate(),
|
startTime: dayjs(evt.startTime).toDate(),
|
||||||
endTime: dayjs(evt.endTime).toDate(),
|
endTime: dayjs(evt.endTime).toDate(),
|
||||||
description: evt.description,
|
description: evt.description,
|
||||||
confirmed: !eventType.requiresConfirmation || !!rescheduleUid,
|
confirmed: !eventType?.requiresConfirmation || !!rescheduleUid,
|
||||||
location: evt.location,
|
location: evt.location,
|
||||||
eventType: {
|
eventType: {
|
||||||
connect: {
|
connect: {
|
||||||
|
@ -323,9 +324,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
let booking: Booking | null = null;
|
let booking: Booking | null = null;
|
||||||
try {
|
try {
|
||||||
booking = await createBooking();
|
booking = await createBooking();
|
||||||
} catch (e) {
|
} catch (_err) {
|
||||||
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", e.message);
|
const err = getErrorFromUnknown(_err);
|
||||||
if (e.code === "P2002") {
|
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
|
||||||
|
if (err.code === "P2002") {
|
||||||
res.status(409).json({ message: "booking.conflict" });
|
res.status(409).json({ message: "booking.conflict" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -361,7 +363,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
);
|
);
|
||||||
|
|
||||||
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter((time) => time);
|
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter((time) => time);
|
||||||
calendarBusyTimes.push(...videoBusyTimes);
|
calendarBusyTimes.push(...(videoBusyTimes as any[])); // FIXME add types
|
||||||
console.log("calendarBusyTimes==>>>", calendarBusyTimes);
|
console.log("calendarBusyTimes==>>>", calendarBusyTimes);
|
||||||
|
|
||||||
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
|
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
|
||||||
|
|
|
@ -1,23 +1,27 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { getSession } from "next-auth/client";
|
|
||||||
|
|
||||||
|
import { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req: req });
|
||||||
if (!session) {
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) {
|
||||||
return res.status(401).json({ message: "Not authenticated" });
|
return res.status(401).json({ message: "Not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/webhook/{hook}
|
// GET /api/webhook/{hook}
|
||||||
const webhooks = await prisma.webhook.findFirst({
|
const webhook = await prisma.webhook.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: String(req.query.hook),
|
id: String(req.query.hook),
|
||||||
userId: session.user.id,
|
userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!webhook) {
|
||||||
|
return res.status(404).json({ message: "Invalid Webhook" });
|
||||||
|
}
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
return res.status(200).json({ webhooks: webhooks });
|
return res.status(200).json({ webhook });
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/webhook/{hook}
|
// DELETE /api/webhook/{hook}
|
||||||
|
@ -31,19 +35,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "PATCH") {
|
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({
|
await prisma.webhook.update({
|
||||||
where: {
|
where: {
|
||||||
id: req.query.hook as string,
|
id: webhook.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
subscriberUrl: req.body.subscriberUrl,
|
subscriberUrl: req.body.subscriberUrl,
|
||||||
|
|
|
@ -138,6 +138,7 @@ function WebhookDialogForm(props: {
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
|
data-testid="WebhookDialogForm"
|
||||||
form={form}
|
form={form}
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
form
|
form
|
||||||
|
@ -248,7 +249,7 @@ function WebhookEmbed(props: { webhooks: TWebhook[] }) {
|
||||||
<ListItemText component="p">Automation</ListItemText>
|
<ListItemText component="p">Automation</ListItemText>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button color="secondary" onClick={() => setNewWebhookModal(true)}>
|
<Button color="secondary" onClick={() => setNewWebhookModal(true)} data-testid="new_webhook">
|
||||||
{t("new_webhook")}
|
{t("new_webhook")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,6 +5,7 @@ import timezone from "dayjs/plugin/timezone";
|
||||||
import toArray from "dayjs/plugin/toArray";
|
import toArray from "dayjs/plugin/toArray";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { createEvent } from "ics";
|
import { createEvent } from "ics";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
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);
|
const eventName = getEventName(name, props.eventType.title, props.eventType.eventName);
|
||||||
|
|
||||||
function eventLink(): string {
|
function eventLink(): string {
|
||||||
const optional: { location?: string | string[] } = {};
|
const optional: { location?: string } = {};
|
||||||
if (location) {
|
if (location) {
|
||||||
optional["location"] = location;
|
optional["location"] = Array.isArray(location) ? location[0] : location;
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = createEvent({
|
const event = createEvent({
|
||||||
|
@ -51,7 +52,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
.utc()
|
.utc()
|
||||||
.toArray()
|
.toArray()
|
||||||
.slice(0, 6)
|
.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",
|
startInputType: "utc",
|
||||||
title: eventName,
|
title: eventName,
|
||||||
description: props.eventType.description ? props.eventType.description : undefined,
|
description: props.eventType.description ? props.eventType.description : undefined,
|
||||||
|
@ -71,7 +72,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(isReady && (
|
(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
|
<HeadSeo
|
||||||
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
|
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
|
||||||
description={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,
|
notFound: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!eventType.users.length && eventType.userId) {
|
if (!eventType.users.length && eventType.userId) {
|
||||||
eventType.users.push(
|
// TODO we should add `user User` relation on `EventType` so this extra query isn't needed
|
||||||
await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: eventType.userId,
|
id: eventType.userId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
theme: true,
|
name: true,
|
||||||
hideBranding: true,
|
hideBranding: true,
|
||||||
name: true,
|
plan: true,
|
||||||
plan: true,
|
theme: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
);
|
if (user) {
|
||||||
|
eventType.users.push(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!eventType.users.length) {
|
if (!eventType.users.length) {
|
||||||
|
|
99
playwright/integrations.test.ts
Normal file
99
playwright/integrations.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -37,7 +37,7 @@ export function loginProvider(opts: {
|
||||||
waitForSelector?: string;
|
waitForSelector?: string;
|
||||||
}): Provider<Needs, Contributes> {
|
}): Provider<Needs, Contributes> {
|
||||||
return provider<Needs, Contributes>()
|
return provider<Needs, Contributes>()
|
||||||
.name("page")
|
.name("login")
|
||||||
.before(async () => {
|
.before(async () => {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
export function randomString(length: number) {
|
import { createServer, IncomingMessage, ServerResponse } from "http";
|
||||||
|
|
||||||
|
export function randomString(length = 12) {
|
||||||
let result = "";
|
let result = "";
|
||||||
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
const charactersLength = characters.length;
|
const charactersLength = characters.length;
|
||||||
|
@ -7,3 +9,65 @@ export function randomString(length: number) {
|
||||||
}
|
}
|
||||||
return result;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -1779,10 +1779,15 @@
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
"@types/socket.io" "2.1.13"
|
"@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"
|
version "16.10.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.10.1.tgz#f3647623199ca920960006b3dccf633ea905f243"
|
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":
|
"@types/nodemailer@^6.4.4":
|
||||||
version "6.4.4"
|
version "6.4.4"
|
||||||
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.4.tgz#c265f7e7a51df587597b3a49a023acaf0c741f4b"
|
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-mimetype "^2.3.0"
|
||||||
whatwg-url "^8.0.0"
|
whatwg-url "^8.0.0"
|
||||||
|
|
||||||
dayjs-business-days@^1.0.4:
|
dayjs-business-time@^1.0.4:
|
||||||
version "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"
|
version "1.10.7"
|
||||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
|
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue