E2E fixtures (#2747)

This commit is contained in:
Omar López
2022-05-13 21:02:10 -06:00
committed by GitHub
parent 68e275ab07
commit 2bb6f33112
21 changed files with 491 additions and 428 deletions

View File

@@ -16,11 +16,12 @@ interface AppCardProps {
export default function AppCard(props: AppCardProps) {
return (
<Link href={"/apps/" + props.slug}>
<a className="block h-full rounded-sm border border-gray-300 p-5 hover:bg-neutral-50">
<a
className="block h-full rounded-sm border border-gray-300 p-5 hover:bg-neutral-50"
data-testid={`app-store-app-card-${props.slug}`}>
<div className="flex">
<img src={props.logo} alt={props.name + " Logo"} className="mb-4 h-12 w-12 rounded-sm" />
<Button
data-testid={`app-store-app-card-${props.slug}`}
color="secondary"
className="ml-auto flex self-start"
onClick={() => {

View File

@@ -1,5 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -12,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const userId = req.session.user.id;
try {
const installedApp = await prisma?.credential.findFirst({
const installedApp = await prisma.credential.findFirst({
where: {
type: appCredentialType as string,
userId: userId,

View File

@@ -1,11 +1,16 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
test("Can reset forgotten password", async ({ page }) => {
import { test } from "../lib/fixtures";
test("Can reset forgotten password", async ({ page, users }) => {
const user = await users.create();
const newPassword = `${user.username!}-123`;
// Got to reset password flow
await page.goto("/auth/forgot-password");
await page.waitForSelector("text=Forgot Password");
// Fill [placeholder="john.doe@example.com"]
await page.fill('input[name="email"]', "pro@example.com");
await page.fill('input[name="email"]', `${user.username}@example.com`);
// Press Enter
await Promise.all([
@@ -18,7 +23,7 @@ test("Can reset forgotten password", async ({ page }) => {
// Wait for page to fully load
await page.waitForSelector("text=Reset Password");
// Fill input[name="password"]
await page.fill('input[name="password"]', "pro");
await page.fill('input[name="password"]', newPassword);
// Click text=Submit
await page.click('button[type="submit"]');
@@ -33,10 +38,12 @@ test("Can reset forgotten password", async ({ page }) => {
await Promise.all([page.waitForNavigation({ url: "/auth/login" }), page.click('button:has-text("Login")')]);
// Fill input[name="email"]
await page.fill('input[name="email"]', "pro@example.com");
await page.fill('input[name="password"]', "pro");
await page.fill('input[name="email"]', `${user.username}@example.com`);
await page.fill('input[name="password"]', newPassword);
await page.press('input[name="password"]', "Enter");
await page.waitForSelector("[data-testid=dashboard-shell]");
await expect(page.locator("[data-testid=dashboard-shell]")).toBeVisible();
await users.deleteAll();
});

View File

@@ -1,29 +1,28 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import { test } from "./lib/fixtures";
import {
bookFirstEvent,
bookTimeSlot,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
todo,
} from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("free user", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/free");
test.beforeEach(async ({ page, users }) => {
const free = await users.create({ plan: "FREE" });
await page.goto(`/${free.username}`);
});
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test.afterEach(async () => {
// delete test bookings
await deleteAllBookingsByEmail("free@example.com");
});
test("only one visible event", async ({ page }) => {
await expect(page.locator(`[href="/free/30min"]`)).toBeVisible();
await expect(page.locator(`[href="/free/60min"]`)).not.toBeVisible();
test("only one visible event", async ({ page, users }) => {
const [free] = users.get();
await expect(page.locator(`[href="/${free.username}/${free.eventTypes[0].slug}"]`)).toBeVisible();
await expect(page.locator(`[href="/${free.username}/${free.eventTypes[1].slug}"]`)).not.toBeVisible();
});
test("cannot book same slot multiple times", async ({ page }) => {
@@ -58,32 +57,24 @@ test.describe("free user", () => {
await expect(page.locator("[data-testid=booking-fail]")).toBeVisible();
});
// Why do we need this test. The previous test is testing /30min booking only ?
todo("`/free/30min` is bookable");
test("`/free/60min` is not bookable", async ({ page }) => {
test("Second event type is not bookable", async ({ page, users }) => {
const [free] = users.get();
// Not available in listing
await expect(page.locator('[href="/free/60min"]')).toHaveCount(0);
await expect(page.locator(`[href="/${free.username}/${free.eventTypes[1].slug}"]`)).toHaveCount(0);
await page.goto("/free/60min");
await page.goto(`/${free.username}/${free.eventTypes[1].slug}`);
// Not available on a direct visit to event type page
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
});
});
test.describe("pro user", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test.beforeAll(async () => {
await deleteAllBookingsByEmail("pro@example.com");
test.beforeEach(async ({ page, users }) => {
const pro = await users.create();
await page.goto(`/${pro.username}`);
});
test.beforeEach(async ({ page }) => {
await page.goto("/pro");
});
test.afterAll(async () => {
await deleteAllBookingsByEmail("pro@example.com");
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("pro user's page has at least 2 visible events", async ({ page }) => {
@@ -96,9 +87,12 @@ test.describe("pro user", () => {
await bookFirstEvent(page);
});
test("can reschedule a booking", async ({ page }) => {
await bookFirstEvent(page);
test("can reschedule a booking", async ({ page, users, bookings }) => {
const [pro] = users.get();
const [eventType] = pro.eventTypes;
await bookings.create(pro.id, pro.username, eventType.id);
await pro.login();
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="reschedule"]').nth(0).click();
await page.locator('[data-testid="edit"]').click();
@@ -118,9 +112,12 @@ test.describe("pro user", () => {
});
});
test("Can cancel the recently created booking and rebook the same timeslot", async ({ page }) => {
test("Can cancel the recently created booking and rebook the same timeslot", async ({ page, users }) => {
await bookFirstEvent(page);
const [pro] = users.get();
await pro.login();
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="cancel"]').first().click();
await page.waitForNavigation({
@@ -135,7 +132,7 @@ test.describe("pro user", () => {
return url.pathname === "/cancel/success";
},
});
await page.goto("/pro");
await page.goto(`/${pro.username}`);
await bookFirstEvent(page);
});
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import { test } from "./lib/fixtures";
import {
bookFirstEvent,
bookTimeSlot,
@@ -11,18 +11,15 @@ import {
test.describe.configure({ mode: "parallel" });
test.describe("dynamic booking", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test.beforeEach(async ({ page }) => {
await deleteAllBookingsByEmail("pro@example.com");
await deleteAllBookingsByEmail("free@example.com");
await page.goto("/pro+free");
test.beforeEach(async ({ page, users }) => {
const pro = await users.create();
await pro.login();
const free = await users.create({ plan: "FREE" });
await page.goto(`/${pro.username}+${free.username}`);
});
test.afterEach(async () => {
// delete test bookings
await deleteAllBookingsByEmail("pro@example.com");
await deleteAllBookingsByEmail("free@example.com");
test.afterEach(async ({ page, users }) => {
await users.deleteAll();
});
test("book an event first day in next month", async ({ page }) => {

View File

@@ -5,6 +5,8 @@ function chooseEmbedType(page: Page, embedType: string) {
}
async function gotToPreviewTab(page: Page) {
// To prevent early timeouts
await page.waitForTimeout(1000);
await page.locator("[data-testid=embed-tabs]").locator("text=Preview").click();
}
@@ -179,7 +181,7 @@ test.describe("Embed Code Generator Tests", () => {
embedType: "inline",
});
gotToPreviewTab(page);
await gotToPreviewTab(page);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",

View File

@@ -0,0 +1,88 @@
import type { Page } from "@playwright/test";
import type { Booking, Prisma } from "@prisma/client";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import short from "short-uuid";
import { v5 as uuidv5, v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
dayjs.extend(utc);
const translator = short();
type BookingFixture = ReturnType<typeof createBookingFixture>;
// creates a user fixture instance and stores the collection
export const createBookingsFixture = (page: Page) => {
let store = { bookings: [], page } as { bookings: BookingFixture[]; page: typeof page };
return {
create: async (
userId: number,
username: string | null,
eventTypeId = -1,
{
confirmed = true,
rescheduled = false,
paid = false,
status = "ACCEPTED",
}: Partial<Prisma.BookingCreateInput> = {}
) => {
const startDate = dayjs().add(1, "day").toDate();
const seed = `${username}:${dayjs(startDate).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const booking = await prisma.booking.create({
data: {
uid: uid,
title: "30min",
startTime: startDate,
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
user: {
connect: {
id: userId,
},
},
attendees: {
create: {
email: "attendee@example.com",
name: "Attendee Example",
timeZone: "Europe/London",
},
},
eventType: {
connect: {
id: eventTypeId,
},
},
confirmed,
rescheduled,
paid,
status,
},
});
const bookingFixture = createBookingFixture(booking, store.page!);
store.bookings.push(bookingFixture);
return bookingFixture;
},
get: () => store.bookings,
delete: async (id: number) => {
await prisma.booking.delete({
where: { id },
});
store.bookings = store.bookings.filter((b) => b.id !== id);
},
};
};
// creates the single user fixture
const createBookingFixture = (booking: Booking, page: Page) => {
const store = { booking, page };
// self is a reflective method that return the Prisma object that references this fixture.
return {
id: store.booking.id,
uid: store.booking.uid,
self: async () => (await prisma.booking.findUnique({ where: { id: store.booking.id } }))!,
delete: async () => (await prisma.booking.delete({ where: { id: store.booking.id } }))!,
};
};

View File

@@ -0,0 +1,59 @@
import type { Page } from "@playwright/test";
import type { Payment } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
type PaymentFixture = ReturnType<typeof createPaymentFixture>;
// creates a user fixture instance and stores the collection
export const createPaymentsFixture = (page: Page) => {
let store = { payments: [], page } as { payments: PaymentFixture[]; page: typeof page };
return {
create: async (
bookingId: number,
{ success = false, refunded = false }: { success?: boolean; refunded?: boolean } = {}
) => {
const payment = await prisma.payment.create({
data: {
uid: uuidv4(),
amount: 20000,
fee: 160,
currency: "usd",
success,
refunded,
type: "STRIPE",
data: {},
externalId: "DEMO_PAYMENT_FROM_DB_" + Date.now(),
booking: {
connect: {
id: bookingId,
},
},
},
});
const paymentFixture = createPaymentFixture(payment, store.page!);
store.payments.push(paymentFixture);
return paymentFixture;
},
get: () => store.payments,
delete: async (id: number) => {
await prisma.payment.delete({
where: { id },
});
store.payments = store.payments.filter((b) => b.id !== id);
},
};
};
// creates the single user fixture
const createPaymentFixture = (payment: Payment, page: Page) => {
const store = { payment, page };
// self is a reflective method that return the Prisma object that references this fixture.
return {
id: store.payment.id,
self: async () => (await prisma.payment.findUnique({ where: { id: store.payment.id } }))!,
delete: async () => (await prisma.payment.delete({ where: { id: store.payment.id } }))!,
};
};

View File

@@ -1,6 +1,6 @@
import type { Page } from "@playwright/test";
import { UserPlan } from "@prisma/client";
import type { Page, WorkerInfo } from "@playwright/test";
import type Prisma from "@prisma/client";
import { Prisma as PrismaType, UserPlan } from "@prisma/client";
import { hashPassword } from "@calcom/lib/auth";
import { prisma } from "@calcom/prisma";
@@ -9,13 +9,42 @@ import { TimeZoneEnum } from "./types";
type UserFixture = ReturnType<typeof createUserFixture>;
const userIncludes = PrismaType.validator<PrismaType.UserInclude>()({
eventTypes: true,
credentials: true,
});
const userWithEventTypes = PrismaType.validator<PrismaType.UserArgs>()({
include: userIncludes,
});
type UserWithIncludes = PrismaType.UserGetPayload<typeof userWithEventTypes>;
// creates a user fixture instance and stores the collection
export const createUsersFixture = (page: Page) => {
export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
let store = { users: [], page } as { users: UserFixture[]; page: typeof page };
return {
create: async (opts?: CustomUserOpts) => {
const user = await prisma.user.create({
data: await createUser(opts),
const _user = await prisma.user.create({
data: await createUser(workerInfo, opts),
});
await prisma.eventType.create({
data: {
users: {
connect: {
id: _user.id,
},
},
title: "Paid",
slug: "paid",
length: 30,
price: 1000,
},
});
const user = await prisma.user.findUnique({
rejectOnNotFound: true,
where: { id: _user.id },
include: userIncludes,
});
const userFixture = createUserFixture(user, store.page!);
store.users.push(userFixture);
@@ -25,25 +54,39 @@ export const createUsersFixture = (page: Page) => {
logout: async () => {
await page.goto("/auth/logout");
},
deleteAll: async () => {
const ids = store.users.map((u) => u.id);
await prisma.user.deleteMany({ where: { id: { in: ids } } });
store.users = [];
},
delete: async (id: number) => {
await prisma.user.delete({ where: { id } });
store.users = store.users.filter((b) => b.id !== id);
},
};
};
type JSONValue = string | number | boolean | { [x: string]: JSONValue } | Array<JSONValue>;
// creates the single user fixture
const createUserFixture = (user: Prisma.User, page: Page) => {
const createUserFixture = (user: UserWithIncludes, page: Page) => {
const store = { user, page };
// self is a reflective method that return the Prisma object that references this fixture.
const self = async () => (await prisma.user.findUnique({ where: { id: store.user.id } }))!;
const self = async () =>
(await prisma.user.findUnique({ where: { id: store.user.id }, include: { eventTypes: true } }))!;
return {
id: user.id,
username: user.username,
eventTypes: user.eventTypes!,
self,
login: async () => login({ ...(await self()), password: user.username }, store.page),
getPaymentCredential: async () => getPaymentCredential(store.page),
// ths is for developemnt only aimed to inject debugging messages in the metadata field of the user
debug: async (message: string | Record<string, JSONValue>) => {
await prisma.user.update({ where: { id: store.user.id }, data: { metadata: { debug: message } } });
},
delete: async () => (await prisma.user.delete({ where: { id: store.user.id } }))!,
};
};
@@ -51,10 +94,14 @@ type CustomUserOptsKeys = "username" | "password" | "plan" | "completedOnboardin
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & { timeZone?: TimeZoneEnum };
// creates the actual user in the db.
const createUser = async (opts?: CustomUserOpts) => {
const createUser = async (
workerInfo: WorkerInfo,
opts?: CustomUserOpts
): Promise<PrismaType.UserCreateInput> => {
// build a unique name for our user
const uname =
(opts?.username ?? opts?.plan?.toLocaleLowerCase() ?? UserPlan.PRO.toLowerCase()) + "-" + Date.now();
const uname = `${opts?.username ?? opts?.plan?.toLocaleLowerCase() ?? UserPlan.PRO.toLowerCase()}-${
workerInfo.workerIndex
}-${Date.now()}`;
return {
username: uname,
name: (opts?.username ?? opts?.plan ?? UserPlan.PRO).toUpperCase(),
@@ -65,6 +112,13 @@ const createUser = async (opts?: CustomUserOpts) => {
completedOnboarding: opts?.completedOnboarding ?? true,
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
locale: opts?.locale ?? "en",
eventTypes: {
create: {
title: "30 min",
slug: "30-min",
length: 30,
},
},
};
};
@@ -74,10 +128,10 @@ export async function login(
page: Page
) {
// get locators
const loginLocator = await page.locator("[data-testid=login-form]");
const emailLocator = await loginLocator.locator("#email");
const passwordLocator = await loginLocator.locator("#password");
const signInLocator = await loginLocator.locator('[type="submit"]');
const loginLocator = page.locator("[data-testid=login-form]");
const emailLocator = loginLocator.locator("#email");
const passwordLocator = loginLocator.locator("#password");
const signInLocator = loginLocator.locator('[type="submit"]');
//login
await page.goto("/");
@@ -88,3 +142,19 @@ export async function login(
// 2 seconds of delay to give the session enough time for a clean load
await page.waitForTimeout(2000);
}
export async function getPaymentCredential(page: Page) {
await page.goto("/apps/installed");
/** We start the Stripe flow */
await Promise.all([
page.waitForNavigation({ url: "https://connect.stripe.com/oauth/v2/authorize?*" }),
page.click('li:has-text("Stripe") >> [data-testid="integration-connection-button"]'),
]);
await Promise.all([
page.waitForNavigation({ url: "/apps/installed" }),
/** We skip filling Stripe forms (testing mode only) */
page.click('[id="skip-account-app"]'),
]);
}

View File

@@ -1,23 +1,18 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import { test } from "./lib/fixtures";
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("hash my url", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
let $url = "";
test.beforeEach(async ({ page }) => {
await deleteAllBookingsByEmail("pro@example.com");
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
test.beforeEach(async ({ users }) => {
const user = await users.create();
await user.login();
});
test.afterAll(async () => {
// delete test bookings
await deleteAllBookingsByEmail("pro@example.com");
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("generate url hash", async ({ page }) => {
// await page.pause();
await page.goto("/event-types");
@@ -34,22 +29,22 @@ test.describe("hash my url", () => {
!isChecked && (await page.click('//*[@id="hashedLinkCheck"]'));
// we wait for the hashedLink setting to load
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
$url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
const $url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
// click update
await page.focus('//button[@type="submit"]');
await page.keyboard.press("Enter");
});
test("book using generated url hash", async ({ page }) => {
// To prevent an early 404
await page.waitForTimeout(1000);
// book using generated url hash
await page.goto($url);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("hash regenerates after successful booking", async ({ page }) => {
// hash regenerates after successful booking
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');

View File

@@ -1,53 +1,45 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import * as teardown from "./lib/teardown";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, todo } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
const IS_STRIPE_ENABLED = !!(
process.env.STRIPE_CLIENT_ID &&
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_PRIVATE_KEY
);
test.describe.serial("Stripe integration", () => {
test.afterAll(() => {
teardown.deleteAllPaymentsByEmail("pro@example.com");
teardown.deleteAllBookingsByEmail("pro@example.com");
teardown.deleteAllPaymentCredentialsByEmail("pro@example.com");
});
test.describe("Stripe integration", () => {
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
test.describe.serial("Stripe integration dashboard", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test("Can add Stripe integration", async ({ page }) => {
test.describe("Stripe integration dashboard", () => {
test("Can add Stripe integration", async ({ page, users }) => {
const user = await users.create();
await user.login();
await page.goto("/apps/installed");
/** We should see the "Connect" button for Stripe */
await expect(
page.locator(`li:has-text("Stripe") >> [data-testid="integration-connection-button"]`)
).toContainText("Connect");
/** We start the Stripe flow */
await Promise.all([
page.waitForNavigation({ url: "https://connect.stripe.com/oauth/v2/authorize?*" }),
page.click('li:has-text("Stripe") >> [data-testid="integration-connection-button"]'),
]);
await Promise.all([
page.waitForNavigation({ url: "/apps/installed" }),
/** We skip filling Stripe forms (testing mode only) */
page.click('[id="skip-account-app"]'),
]);
await user.getPaymentCredential();
/** If Stripe is added correctly we should see the "Disconnect" button */
await expect(
page.locator(`li:has-text("Stripe") >> [data-testid="integration-connection-button"]`)
).toContainText("Disconnect");
// Cleanup
await user.delete();
});
});
test("Can book a paid booking", async ({ page }) => {
await page.goto("/pro/paid");
test("Can book a paid booking", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
await user.login();
await page.goto("/apps/installed");
await user.getPaymentCredential();
await page.goto(`${user.username}/${eventType.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
await page.fill('[name="name"]', "Stripe Stripeson");
@@ -72,6 +64,9 @@ test.describe.serial("Stripe integration", () => {
// Make sure we're navigated to the success page
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// Cleanup
await user.delete();
});
todo("Pending payment booking should not be confirmed by default");

View File

@@ -1,20 +1,11 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import * as teardown from "./lib/teardown";
import { test } from "./lib/fixtures";
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth, todo, waitFor } from "./lib/testUtils";
test.describe("integrations", () => {
//teardown
test.afterAll(async () => {
await teardown.deleteAllWebhooksByEmail("pro@example.com");
await teardown.deleteAllBookingsByEmail("pro@example.com");
});
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test.beforeEach(async ({ page }) => {
await page.goto("/apps/installed");
});
test.describe.configure({ mode: "parallel" });
test.describe("Integrations", () => {
todo("Can add Zoom integration");
todo("Can add Google Calendar");
@@ -25,8 +16,15 @@ test.describe("integrations", () => {
todo("Can add Apple Calendar");
test("add webhook & test that creating an event triggers a webhook call", async ({ page }, testInfo) => {
test("add webhook & test that creating an event triggers a webhook call", async ({
page,
users,
}, testInfo) => {
const webhookReceiver = createHttpServer();
const user = await users.create();
const [eventType] = user.eventTypes;
await user.login();
await page.goto("/apps/installed");
// --- add webhook
await page.click('[data-testid="new_webhook"]');
@@ -43,7 +41,7 @@ test.describe("integrations", () => {
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
// --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`/pro/30min`);
await page.goto(`/${user.username}/${eventType.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
@@ -69,6 +67,7 @@ test.describe("integrations", () => {
attendee.timeZone = dynamic;
attendee.language = dynamic;
}
body.payload.organizer.email = dynamic;
body.payload.organizer.timeZone = dynamic;
body.payload.organizer.language = dynamic;
body.payload.uid = dynamic;

View File

@@ -1 +1 @@
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30min","title":"30min between Pro Example and Test Testson","description":"","additionalNotes":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Pro Example","email":"pro@example.com","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}}
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}}

View File

@@ -1,80 +0,0 @@
import { Booking } from "@prisma/client";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import short from "short-uuid";
import { v5 as uuidv5, v4 as uuidv4 } from "uuid";
dayjs.extend(utc);
const translator = short();
const TestUtilCreateBookingOnUserId = async (
userId: number,
username: string,
eventTypeId: number,
{ confirmed = true, rescheduled = false, paid = false, status = "ACCEPTED" }: Partial<Booking>
) => {
const startDate = dayjs().add(1, "day").toDate();
const seed = `${username}:${dayjs(startDate).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
return await prisma?.booking.create({
data: {
uid: uid,
title: "30min",
startTime: startDate,
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
user: {
connect: {
id: userId,
},
},
attendees: {
create: {
email: "attendee@example.com",
name: "Attendee Example",
timeZone: "Europe/London",
},
},
eventType: {
connect: {
id: eventTypeId,
},
},
confirmed,
rescheduled,
paid,
status,
},
select: {
id: true,
uid: true,
user: true,
},
});
};
const TestUtilCreatePayment = async (
bookingId: number,
{ success = false, refunded = false }: { success?: boolean; refunded?: boolean }
) => {
return await prisma?.payment.create({
data: {
uid: uuidv4(),
amount: 20000,
fee: 160,
currency: "usd",
success,
refunded,
type: "STRIPE",
data: {},
externalId: "DEMO_PAYMENT_FROM_DB",
booking: {
connect: {
id: bookingId,
},
},
},
});
};
export { TestUtilCreateBookingOnUserId, TestUtilCreatePayment };

View File

@@ -1,14 +1,29 @@
import { test as base } from "@playwright/test";
import { createBookingsFixture } from "../fixtures/bookings";
import { createPaymentsFixture } from "../fixtures/payments";
import { createUsersFixture } from "../fixtures/users";
interface Fixtures {
users: ReturnType<typeof createUsersFixture>;
bookings: ReturnType<typeof createBookingsFixture>;
payments: ReturnType<typeof createPaymentsFixture>;
}
/**
* @see https://playwright.dev/docs/test-fixtures
*/
export const test = base.extend<Fixtures>({
users: async ({ page }, use) => {
const usersFixture = createUsersFixture(page);
users: async ({ page }, use, workerInfo) => {
const usersFixture = createUsersFixture(page, workerInfo);
await use(usersFixture);
},
bookings: async ({ page }, use) => {
const bookingsFixture = createBookingsFixture(page);
await use(bookingsFixture);
},
payments: async ({ page }, use) => {
const payemntsFixture = createPaymentsFixture(page);
await use(payemntsFixture);
},
});

View File

@@ -2,6 +2,14 @@ import { Prisma } from "@prisma/client";
import prisma from "@lib/prisma";
/**
* @deprecated
* DO NOT USE, since test run in parallel this will cause flaky tests. The reason
* being that a set of test may end earlier than other trigger a delete of all bookings
* than other tests may depend on them. The proper ettiquete should be that EACH test
* should cleanup ONLY the booking that we're created in that specific test to se DB
* remains "pristine" after each test
*/
export const deleteAllBookingsByEmail = async (
email: string,
whereConditional: Prisma.BookingWhereInput = {}

View File

@@ -1,22 +1,25 @@
import { expect } from "@playwright/test";
import { UserPlan } from "@prisma/client";
import { ErrorCode } from "@lib/auth";
import { login } from "./fixtures/users";
import { test } from "./lib/fixtures";
import { localize } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("Login and logout tests", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
// Test login with all plans
const plans = [UserPlan.PRO, UserPlan.FREE, UserPlan.TRIAL];
plans.forEach((plan) => {
test(`Should login with a ${plan} account`, async ({ page, users }) => {
// Create user and login
const pro = await users.create({ plan });
await pro.login();
const user = await users.create({ plan });
await user.login();
const shellLocator = await page.locator(`[data-testid=dashboard-shell]`);
const shellLocator = page.locator(`[data-testid=dashboard-shell]`);
// expects the home page for an authorized user
await page.goto("/");
@@ -25,7 +28,7 @@ test.describe("Login and logout tests", () => {
// Asserts to read the tested plan
const planLocator = shellLocator.locator(`[data-testid=plan-${plan.toLowerCase()}]`);
await expect(planLocator).toBeVisible();
await await expect(planLocator).toHaveText;
await expect(planLocator).toHaveText(`-${plan}`);
// When TRIAL check if the TRIAL banner is visible
if (plan === UserPlan.TRIAL) {
@@ -34,7 +37,7 @@ test.describe("Login and logout tests", () => {
});
});
test("Should warn when user does not exist", async ({ page, users }) => {
test("Should warn when user does not exist", async ({ page }) => {
const alertMessage = (await localize("en"))("no_account_exists");
// Login with a non-existent user
@@ -51,7 +54,7 @@ test.describe("Login and logout tests", () => {
const pro = await users.create({ username: "pro" });
// login with a wrong password
await login({ username: "pro", password: "wrong" }, page);
await login({ username: pro.username, password: "wrong" }, page);
// assert for the visibility of the localized alert message
await expect(page.locator(`text=${alertMessage}`)).toBeVisible();
@@ -59,8 +62,7 @@ test.describe("Login and logout tests", () => {
test("Should logout", async ({ page, users }) => {
const signOutLabel = (await localize("en"))("sign_out");
const userDropdownDisclose = async () =>
(await page.locator("[data-testid=user-dropdown-trigger]")).click();
const userDropdownDisclose = async () => page.locator("[data-testid=user-dropdown-trigger]").click();
// creates a user and login
const pro = await users.create();
@@ -68,7 +70,7 @@ test.describe("Login and logout tests", () => {
// disclose and click the sign out button from the user dropdown
await userDropdownDisclose();
const signOutBtn = await page.locator(`text=${signOutLabel}`);
const signOutBtn = page.locator(`text=${signOutLabel}`);
await signOutBtn.click();
// 2s of delay to assure the session is cleared

View File

@@ -30,6 +30,11 @@ test.describe("Onboarding", () => {
test.describe("Onboarding", () => {
test("update onboarding username via localstorage", async ({ page }) => {
/**
* We need to come up with a better test since all test are run in an incognito window.
* Meaning that all localstorage access is null here.
*/
test.fixme();
await page.addInitScript(() => {
window.localStorage.setItem("username", "alwaysavailable");
}, {});

View File

@@ -1,11 +1,9 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs";
import prisma from "@lib/prisma";
import { TestUtilCreateBookingOnUserId, TestUtilCreatePayment } from "./lib/dbSetup";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
const IS_STRIPE_ENABLED = !!(
@@ -13,51 +11,21 @@ const IS_STRIPE_ENABLED = !!(
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_PRIVATE_KEY
);
const findUserByEmail = async (email: string) => {
return await prisma.user.findFirst({
select: {
id: true,
email: true,
username: true,
credentials: true,
},
where: {
email,
},
});
};
test.describe.configure({ mode: "parallel" });
test.describe("Reschedule Tests", async () => {
let currentUser: Awaited<ReturnType<typeof findUserByEmail>>;
// Using logged in state from globalSetup
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test.beforeAll(async () => {
currentUser = await findUserByEmail("pro@example.com");
});
test.afterEach(async () => {
try {
await deleteAllBookingsByEmail("pro@example.com", {
createdAt: { gte: dayjs().startOf("day").toISOString() },
});
} catch (error) {
console.log("Error while trying to delete all bookings from pro user");
}
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
const user = await users.create();
test("Should do a booking request reschedule from /bookings", async ({ page }) => {
const user = currentUser;
const eventType = await prisma.eventType.findFirst({
where: {
userId: user?.id,
slug: "30min",
},
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.ACCEPTED,
});
let originalBooking;
if (user && user.id && user.username && eventType) {
originalBooking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
status: BookingStatus.ACCEPTED,
});
}
await user.login();
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="reschedule"]').nth(0).click();
@@ -67,176 +35,119 @@ test.describe("Reschedule Tests", async () => {
await page.fill('[data-testid="reschedule_reason"]', "I can't longer have it");
await page.locator('button[data-testid="send_request"]').click();
await expect(page.locator('[id="modal-title"]')).not.toBeVisible();
await page.goto("/bookings/cancelled");
const updatedBooking = await booking.self();
// Find booking that was recently cancelled
const booking = await prisma.booking.findFirst({
select: {
id: true,
uid: true,
cancellationReason: true,
status: true,
rescheduled: true,
},
where: { id: originalBooking?.id },
});
expect(booking?.rescheduled).toBe(true);
expect(booking?.cancellationReason).toBe("I can't longer have it");
expect(booking?.status).toBe(BookingStatus.CANCELLED);
expect(updatedBooking.rescheduled).toBe(true);
expect(updatedBooking.cancellationReason).toBe("I can't longer have it");
expect(updatedBooking.status).toBe(BookingStatus.CANCELLED);
await booking.delete();
});
test("Should display former time when rescheduling availability", async ({ page }) => {
const user = currentUser;
const eventType = await prisma.eventType.findFirst({
where: {
userId: user?.id,
slug: "30min",
},
test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => {
const user = await users.create();
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.CANCELLED,
rescheduled: true,
});
let originalBooking;
if (user && user.id && user.username && eventType) {
originalBooking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
status: BookingStatus.CANCELLED,
rescheduled: true,
});
}
await page.goto(
`/${originalBooking?.user?.username}/${eventType?.slug}?rescheduleUid=${originalBooking?.uid}`
);
const formerTimeElement = await page.locator('[data-testid="former_time_p_desktop"]');
await page.goto(`/${user.username}/${user.eventTypes[0].slug}?rescheduleUid=${booking.uid}`);
const formerTimeElement = page.locator('[data-testid="former_time_p_desktop"]');
await expect(formerTimeElement).toBeVisible();
await booking.delete();
});
test("Should display request reschedule send on bookings/cancelled", async ({ page }) => {
const user = currentUser;
const eventType = await prisma.eventType.findFirst({
where: {
userId: user?.id,
slug: "30min",
},
test("Should display request reschedule send on bookings/cancelled", async ({ page, users, bookings }) => {
const user = await users.create();
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id, {
status: BookingStatus.CANCELLED,
rescheduled: true,
});
if (user && user.id && user.username && eventType) {
await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
status: BookingStatus.CANCELLED,
rescheduled: true,
});
}
await user.login();
await page.goto("/bookings/cancelled");
const requestRescheduleSentElement = await page.locator('[data-testid="request_reschedule_sent"]').nth(1);
const requestRescheduleSentElement = page.locator('[data-testid="request_reschedule_sent"]').nth(1);
await expect(requestRescheduleSentElement).toBeVisible();
await booking.delete();
});
test("Should do a reschedule from user owner", async ({ page }) => {
const user = currentUser;
test("Should do a reschedule from user owner", async ({ page, users, bookings }) => {
const user = await users.create();
const [eventType] = user.eventTypes;
const booking = await bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.CANCELLED,
rescheduled: true,
});
const eventType = await prisma.eventType.findFirst({
where: {
userId: user?.id,
await page.goto(`/${user.username}/${eventType.slug}?rescheduleUid=${booking.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await expect(page.locator('[name="name"]')).toBeDisabled();
await expect(page.locator('[name="email"]')).toBeDisabled();
await expect(page.locator('[name="notes"]')).toBeDisabled();
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// NOTE: remove if old booking should not be deleted
expect(await booking.self()).toBeNull();
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking.uid } });
expect(newBooking).not.toBeNull();
await prisma.booking.delete({ where: { id: newBooking?.id } });
});
test("Unpaid rescheduling should go to payment page", async ({ page, users, bookings, payments }) => {
test.skip(!IS_STRIPE_ENABLED, "Skipped as Stripe is not installed");
const user = await users.create();
await user.login();
await user.getPaymentCredential();
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
rescheduled: true,
status: BookingStatus.CANCELLED,
paid: false,
});
const payment = await payments.create(booking.id);
await page.goto(`/${user.username}/${eventType.slug}?rescheduleUid=${booking.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForNavigation({
url(url) {
return url.pathname.indexOf("/payment") > -1;
},
});
if (user?.id && user?.username && eventType?.id) {
const booking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
rescheduled: true,
status: BookingStatus.CANCELLED,
});
await page.goto(`/${user?.username}/${eventType?.slug}?rescheduleUid=${booking?.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await expect(page.locator('[name="name"]')).toBeDisabled();
await expect(page.locator('[name="email"]')).toBeDisabled();
await expect(page.locator('[name="notes"]')).toBeDisabled();
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// NOTE: remove if old booking should not be deleted
const oldBooking = await prisma.booking.findFirst({ where: { id: booking?.id } });
expect(oldBooking).toBeNull();
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking?.uid } });
expect(newBooking).not.toBeNull();
}
await expect(page).toHaveURL(/.*payment/);
await payment.delete();
});
test("Unpaid rescheduling should go to payment page", async ({ page }) => {
let user = currentUser;
test.skip(
IS_STRIPE_ENABLED && !(user && user.credentials.length > 0),
"Skipped as stripe is not installed and user is missing credentials"
);
const eventType = await prisma.eventType.findFirst({
where: {
userId: user?.id,
slug: "paid",
},
test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
rescheduled: true,
status: BookingStatus.CANCELLED,
paid: true,
});
if (user?.id && user?.username && eventType?.id) {
const booking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
rescheduled: true,
status: BookingStatus.CANCELLED,
paid: false,
});
if (booking?.id) {
await TestUtilCreatePayment(booking.id, {});
await page.goto(`/${user?.username}/${eventType?.slug}?rescheduleUid=${booking?.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
const payment = await payments.create(booking.id);
await page.goto(`/${user?.username}/${eventType?.slug}?rescheduleUid=${booking?.uid}`);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await selectFirstAvailableTimeSlotNextMonth(page);
await page.waitForNavigation({
url(url) {
return url.pathname.indexOf("/payment") > -1;
},
});
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*payment/);
}
}
});
await expect(page).toHaveURL(/.*success/);
test("Paid rescheduling should go to success page", async ({ page }) => {
let user = currentUser;
try {
const eventType = await prisma.eventType.findFirst({
where: {
userId: user?.id,
slug: "paid",
},
});
if (user?.id && user?.username && eventType?.id) {
const booking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
rescheduled: true,
status: BookingStatus.CANCELLED,
paid: true,
});
if (booking?.id) {
await TestUtilCreatePayment(booking.id, {});
await page.goto(`/${user?.username}/${eventType?.slug}?rescheduleUid=${booking?.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*success/);
}
}
} catch (error) {
await prisma.payment.delete({
where: {
externalId: "DEMO_PAYMENT_FROM_DB",
},
});
}
await payment.delete();
});
});

View File

@@ -15,6 +15,8 @@ addAliases({
"@ee": __dirname + "/apps/web/ee",
});
require("dotenv").config({ path: "../../.env" });
const outputDir = path.join(__dirname, "..", "..", "test-results");
const testDir = path.join(__dirname, "..", "..", "apps/web/playwright");

View File

@@ -2568,7 +2568,7 @@
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
"@prisma/client@^3.12.0", "@prisma/client@^3.13.0":
"@prisma/client@^3.13.0":
version "3.13.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.13.0.tgz#84511ebdf6ba75f77ca08495b9f73f22c4255654"
integrity sha512-lnEA2tTyVbO5mS1ehmHJQKBDiKB8shaR6s3azwj3Azfi5XHIfnqmkolLCvUeFYnkDCNVzGXJpUgKwQt/UOOYVQ==
@@ -2589,11 +2589,6 @@
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b.tgz#676aca309d66d9be2aad8911ca31f1ee5561041c"
integrity sha512-TGp9rvgJIKo8NgvAHSwOosbut9mTA7VC6/rpQI9gh+ySSRjdQFhbGyNUiOcQrlI9Ob2DWeO7y4HEnhdKxYiECg==
"@prisma/engines@3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86":
version "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86.tgz#2964113729a78b8b21e186b5592affd1fde73c16"
integrity sha512-LjRssaWu9w2SrXitofnutRIyURI7l0veQYIALz7uY4shygM9nMcK3omXcObRm7TAcw3Z+9ytfK1B+ySOsOesxQ==
"@prisma/engines@3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b":
version "3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b.tgz#d3a457cec4ef7a3b3412c45b1f2eac68c974474b"
@@ -13687,13 +13682,6 @@ prism-react-renderer@^1.1.1:
resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-1.3.1.tgz#88fc9d0df6bed06ca2b9097421349f8c2f24e30d"
integrity sha512-xUeDMEz074d0zc5y6rxiMp/dlC7C+5IDDlaEUlcBOFE2wddz7hz5PNupb087mPwTt7T9BrFmewObfCBuf/LKwQ==
prisma@3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.10.0.tgz#872d87afbeb1cbcaa77c3d6a63c125e0d704b04d"
integrity sha512-dAld12vtwdz9Rz01nOjmnXe+vHana5PSog8t0XGgLemKsUVsaupYpr74AHaS3s78SaTS5s2HOghnJF+jn91ZrA==
dependencies:
"@prisma/engines" "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
prisma@^3.13.0:
version "3.13.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.13.0.tgz#b11edd5631222ff1bf1d5324732d47801386aa8c"