621 lines
20 KiB
TypeScript
621 lines
20 KiB
TypeScript
import type { Locator, Page, PlaywrightTestArgs } from "@playwright/test";
|
|
import { expect } from "@playwright/test";
|
|
import type { createUsersFixture } from "playwright/fixtures/users";
|
|
import { uuid } from "short-uuid";
|
|
|
|
import prisma from "@calcom/prisma";
|
|
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
|
|
|
import { test } from "./lib/fixtures";
|
|
import { createHttpServer, waitFor, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
|
|
|
async function getLabelText(field: Locator) {
|
|
return await field.locator("label").first().locator("span").first().innerText();
|
|
}
|
|
|
|
test.describe.configure({ mode: "parallel" });
|
|
test.describe("Manage Booking Questions", () => {
|
|
test.afterEach(async ({ users }) => {
|
|
await users.deleteAll();
|
|
});
|
|
|
|
test.describe("For User EventType", () => {
|
|
test("Do a booking with a user added question and verify a few thing in b/w", async ({
|
|
page,
|
|
users,
|
|
context,
|
|
}, testInfo) => {
|
|
// Considering there are many steps in it, it would need more than default test timeout
|
|
test.setTimeout(testInfo.timeout * 3);
|
|
const user = await createAndLoginUserWithEventTypes({ users, page });
|
|
|
|
const webhookReceiver = await addWebhook(user);
|
|
|
|
await test.step("Go to EventType Page ", async () => {
|
|
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
|
const firstEventTypeElement = $eventTypes.first();
|
|
|
|
await firstEventTypeElement.click();
|
|
});
|
|
|
|
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
|
|
});
|
|
|
|
test("Split 'Full name' into 'First name' and 'Last name'", async ({
|
|
page,
|
|
users,
|
|
context,
|
|
}, testInfo) => {
|
|
// Considering there are many steps in it, it would need more than default test timeout
|
|
test.setTimeout(testInfo.timeout * 3);
|
|
const user = await createAndLoginUserWithEventTypes({ page, users });
|
|
const webhookReceiver = await addWebhook(user);
|
|
await test.step("Go to first EventType Page ", async () => {
|
|
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
|
const firstEventTypeElement = $eventTypes.first();
|
|
|
|
await firstEventTypeElement.click();
|
|
});
|
|
|
|
await test.step("Open the 'Name' field dialog", async () => {
|
|
await page.click('[href$="tabName=advanced"]');
|
|
await page.locator('[data-testid="field-name"] [data-testid="edit-field-action"]').click();
|
|
});
|
|
|
|
await test.step("Toggle on the variant toggle and save Event Type", async () => {
|
|
await page.click('[data-testid="variant-toggle"]');
|
|
await page.click("[data-testid=field-add-save]");
|
|
await saveEventType(page);
|
|
});
|
|
|
|
await test.step("Book a time slot with firstName and lastName provided separately", async () => {
|
|
await doOnFreshPreview(page, context, async (page) => {
|
|
await expectSystemFieldsToBeThereOnBookingPage({ page, isFirstAndLastNameVariant: true });
|
|
await bookTimeSlot({
|
|
page,
|
|
name: { firstName: "John", lastName: "Doe" },
|
|
email: "booker@example.com",
|
|
});
|
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
|
expect(await page.locator('[data-testid="attendee-name-John Doe"]').nth(0).textContent()).toBe(
|
|
"John Doe"
|
|
);
|
|
await expectWebhookToBeCalled(webhookReceiver, {
|
|
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
|
|
payload: {
|
|
attendees: [
|
|
{
|
|
// It would have full Name only
|
|
name: "John Doe",
|
|
email: "booker@example.com",
|
|
},
|
|
],
|
|
responses: {
|
|
name: {
|
|
label: "your_name",
|
|
value: {
|
|
firstName: "John",
|
|
lastName: "Doe",
|
|
},
|
|
},
|
|
email: {
|
|
label: "email_address",
|
|
value: "booker@example.com",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
await test.step("Verify that we can prefill name and other fields correctly", async () => {
|
|
await doOnFreshPreview(page, context, async (page) => {
|
|
const url = page.url();
|
|
const prefillUrl = new URL(url);
|
|
prefillUrl.searchParams.append("name", "John Johny Janardan");
|
|
prefillUrl.searchParams.append("email", "john@example.com");
|
|
prefillUrl.searchParams.append("guests", "guest1@example.com");
|
|
prefillUrl.searchParams.append("guests", "guest2@example.com");
|
|
prefillUrl.searchParams.append("notes", "This is an additional note");
|
|
await page.goto(prefillUrl.toString());
|
|
await bookTimeSlot({ page, skipSubmission: true });
|
|
await expectSystemFieldsToBeThereOnBookingPage({
|
|
page,
|
|
isFirstAndLastNameVariant: true,
|
|
values: {
|
|
name: {
|
|
firstName: "John",
|
|
lastName: "Johny Janardan",
|
|
},
|
|
email: "john@example.com",
|
|
guests: ["guest1@example.com", "guest2@example.com"],
|
|
notes: "This is an additional note",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe("For Team EventType", () => {
|
|
test("Do a booking with a user added question and verify a few thing in b/w", async ({
|
|
page,
|
|
users,
|
|
context,
|
|
}, testInfo) => {
|
|
// Considering there are many steps in it, it would need more than default test timeout
|
|
test.setTimeout(testInfo.timeout * 3);
|
|
const user = await createAndLoginUserWithEventTypes({ users, page });
|
|
const team = await prisma.team.findFirst({
|
|
where: {
|
|
members: {
|
|
some: {
|
|
userId: user.id,
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
const teamId = team?.id;
|
|
const webhookReceiver = await addWebhook(undefined, teamId);
|
|
|
|
await test.step("Go to First Team Event", async () => {
|
|
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
|
|
const firstEventTypeElement = $eventTypes.first();
|
|
|
|
await firstEventTypeElement.click();
|
|
});
|
|
|
|
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
|
|
});
|
|
});
|
|
});
|
|
|
|
async function runTestStepsCommonForTeamAndUserEventType(
|
|
page: Page,
|
|
context: PlaywrightTestArgs["context"],
|
|
webhookReceiver: {
|
|
port: number;
|
|
close: () => import("http").Server;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
requestList: (import("http").IncomingMessage & { body?: any })[];
|
|
url: string;
|
|
}
|
|
) {
|
|
await page.click('[href$="tabName=advanced"]');
|
|
|
|
await test.step("Check that all the system questions are shown in the list", async () => {
|
|
await page.locator("[data-testid=field-name]").isVisible();
|
|
await page.locator("[data-testid=field-email]").isVisible();
|
|
await page.locator("[data-testid=field-notes]").isVisible();
|
|
await page.locator("[data-testid=field-guests]").isVisible();
|
|
await page.locator("[data-testid=field-rescheduleReason]").isVisible();
|
|
// It is conditional
|
|
// await page.locator("data-testid=field-location").isVisible();
|
|
});
|
|
|
|
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
|
|
await addQuestionAndSave({
|
|
page,
|
|
question: {
|
|
name: "how-are-you",
|
|
type: "Address",
|
|
label: "How are you?",
|
|
placeholder: "I'm fine, thanks",
|
|
required: true,
|
|
},
|
|
});
|
|
|
|
await doOnFreshPreview(page, context, async (page) => {
|
|
const allFieldsLocator = await expectSystemFieldsToBeThereOnBookingPage({ page });
|
|
const userFieldLocator = allFieldsLocator.nth(5);
|
|
|
|
await expect(userFieldLocator.locator('[name="how-are-you"]')).toBeVisible();
|
|
// There are 2 labels right now. Will be one in future. The second one is hidden
|
|
expect(await getLabelText(userFieldLocator)).toBe("How are you?");
|
|
await expect(userFieldLocator.locator("input")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
|
|
await toggleQuestionAndSave({
|
|
name: "how-are-you",
|
|
page,
|
|
});
|
|
await doOnFreshPreview(page, context, async (page) => {
|
|
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how-are-you"]');
|
|
await expect(formBuilderFieldLocator).toBeHidden();
|
|
});
|
|
});
|
|
|
|
await test.step("Show Question Again", async () => {
|
|
await toggleQuestionAndSave({
|
|
name: "how-are-you",
|
|
page,
|
|
});
|
|
});
|
|
|
|
await test.step('Try to book without providing "How are you?" response', async () => {
|
|
await doOnFreshPreview(page, context, async (page) => {
|
|
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
|
|
await expectErrorToBeThereFor({ page, name: "how-are-you" });
|
|
});
|
|
});
|
|
|
|
await test.step("Make rescheduleReason required - It won't be required for a fresh booking", async () => {
|
|
await toggleQuestionRequireStatusAndSave({
|
|
required: true,
|
|
name: "rescheduleReason",
|
|
page,
|
|
});
|
|
});
|
|
|
|
const previewTabPage =
|
|
await test.step("Do a booking and notice that we can book without giving a value for rescheduleReason", async () => {
|
|
return await doOnFreshPreview(
|
|
page,
|
|
context,
|
|
async (page) => {
|
|
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how-are-you"]');
|
|
await expect(formBuilderFieldLocator).toBeVisible();
|
|
expect(
|
|
await formBuilderFieldLocator.locator('[name="how-are-you"]').getAttribute("placeholder")
|
|
).toBe("I'm fine, thanks");
|
|
expect(await getLabelText(formBuilderFieldLocator)).toBe("How are you?");
|
|
await formBuilderFieldLocator.locator('[name="how-are-you"]').fill("I am great!");
|
|
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
|
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
|
|
|
expect(
|
|
await page.locator('[data-testid="field-response"][data-fob-field="how-are-you"]').innerText()
|
|
).toBe("I am great!");
|
|
|
|
await waitFor(() => {
|
|
expect(webhookReceiver.requestList.length).toBe(1);
|
|
});
|
|
|
|
const [request] = webhookReceiver.requestList;
|
|
|
|
const payload = request.body.payload;
|
|
|
|
expect(payload.responses).toMatchObject({
|
|
email: {
|
|
label: "email_address",
|
|
value: "booker@example.com",
|
|
},
|
|
"how-are-you": {
|
|
label: "How are you?",
|
|
value: "I am great!",
|
|
},
|
|
name: {
|
|
label: "your_name",
|
|
value: "Booker",
|
|
},
|
|
});
|
|
|
|
expect(payload.attendees[0]).toMatchObject({
|
|
name: "Booker",
|
|
email: "booker@example.com",
|
|
});
|
|
|
|
expect(payload.userFieldsResponses).toMatchObject({
|
|
"how-are-you": {
|
|
label: "How are you?",
|
|
value: "I am great!",
|
|
},
|
|
});
|
|
},
|
|
true
|
|
);
|
|
});
|
|
|
|
await test.step("Do a reschedule and notice that we can't book without giving a value for rescheduleReason", async () => {
|
|
const page = previewTabPage;
|
|
await rescheduleFromTheLinkOnPage({ page });
|
|
await expectErrorToBeThereFor({ page, name: "rescheduleReason" });
|
|
});
|
|
}
|
|
|
|
async function expectSystemFieldsToBeThereOnBookingPage({
|
|
page,
|
|
isFirstAndLastNameVariant,
|
|
values,
|
|
}: {
|
|
page: Page;
|
|
isFirstAndLastNameVariant?: boolean;
|
|
values?: Partial<{
|
|
name: {
|
|
firstName?: string;
|
|
lastName?: string;
|
|
fullName?: string;
|
|
};
|
|
email: string;
|
|
notes: string;
|
|
guests: string[];
|
|
}>;
|
|
}) {
|
|
const allFieldsLocator = page.locator("[data-fob-field-name]:not(.hidden)");
|
|
const nameLocator = allFieldsLocator.nth(0);
|
|
const emailLocator = allFieldsLocator.nth(1);
|
|
// Location isn't rendered unless explicitly set which isn't the case here
|
|
// const locationLocator = allFieldsLocator.nth(2);
|
|
const additionalNotes = allFieldsLocator.nth(3);
|
|
const guestsLocator = allFieldsLocator.nth(4);
|
|
|
|
if (isFirstAndLastNameVariant) {
|
|
if (values?.name) {
|
|
await expect(nameLocator.locator('[name="firstName"]')).toHaveValue(values?.name?.firstName || "");
|
|
await expect(nameLocator.locator('[name="lastName"]')).toHaveValue(values?.name?.lastName || "");
|
|
expect(await nameLocator.locator(".testid-firstName > label").innerText()).toContain("*");
|
|
} else {
|
|
await expect(nameLocator.locator('[name="firstName"]')).toBeVisible();
|
|
await expect(nameLocator.locator('[name="lastName"]')).toBeVisible();
|
|
}
|
|
} else {
|
|
if (values?.name) {
|
|
await expect(nameLocator.locator('[name="name"]')).toHaveValue(values?.name?.fullName || "");
|
|
}
|
|
await expect(nameLocator.locator('[name="name"]')).toBeVisible();
|
|
expect(await nameLocator.locator("label").innerText()).toContain("*");
|
|
}
|
|
|
|
if (values?.email) {
|
|
await expect(emailLocator.locator('[name="email"]')).toHaveValue(values?.email || "");
|
|
} else {
|
|
await expect(emailLocator.locator('[name="email"]')).toBeVisible();
|
|
}
|
|
|
|
if (values?.notes) {
|
|
await expect(additionalNotes.locator('[name="notes"]')).toHaveValue(values?.notes);
|
|
} else {
|
|
await expect(additionalNotes.locator('[name="notes"]')).toBeVisible();
|
|
}
|
|
|
|
if (values?.guests) {
|
|
const allGuestsLocators = guestsLocator.locator('[type="email"]');
|
|
for (let i = 0; i < values.guests.length; i++) {
|
|
await expect(allGuestsLocators.nth(i)).toHaveValue(values.guests[i] || "");
|
|
}
|
|
await expect(guestsLocator.locator("[data-testid='add-another-guest']")).toBeVisible();
|
|
} else {
|
|
await expect(guestsLocator.locator("[data-testid='add-guests']")).toBeVisible();
|
|
}
|
|
return allFieldsLocator;
|
|
}
|
|
|
|
//TODO: Add one question for each type and see they are rendering labels and only once and are showing appropriate native component
|
|
// Verify webhook is sent with the correct data, DB is correct (including metadata)
|
|
|
|
//TODO: Verify that prefill works
|
|
async function bookTimeSlot({
|
|
page,
|
|
name,
|
|
email,
|
|
skipSubmission = false,
|
|
}: {
|
|
page: Page;
|
|
name?: string | { firstName: string; lastName?: string };
|
|
email?: string;
|
|
skipSubmission?: boolean;
|
|
}) {
|
|
if (name) {
|
|
if (typeof name === "string") {
|
|
await page.fill('[name="name"]', name);
|
|
} else {
|
|
await page.fill('[name="firstName"]', name.firstName);
|
|
if (name.lastName) {
|
|
await page.fill('[name="lastName"]', name.lastName);
|
|
}
|
|
}
|
|
}
|
|
if (email) {
|
|
await page.fill('[name="email"]', email);
|
|
}
|
|
if (!skipSubmission) {
|
|
await page.press('[name="email"]', "Enter");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 'option' starts from 1
|
|
*/
|
|
async function selectOption({
|
|
page,
|
|
selector,
|
|
optionText,
|
|
}: {
|
|
page: Page;
|
|
selector: { selector: string; nth: number };
|
|
optionText: string;
|
|
}) {
|
|
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
|
|
await locatorForSelect.click();
|
|
await locatorForSelect.locator(`text="${optionText}"`).click();
|
|
}
|
|
|
|
async function addQuestionAndSave({
|
|
page,
|
|
question,
|
|
}: {
|
|
page: Page;
|
|
question: {
|
|
name?: string;
|
|
type?: string;
|
|
label?: string;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
};
|
|
}) {
|
|
await page.click('[data-testid="add-field"]');
|
|
|
|
if (question.type !== undefined) {
|
|
await selectOption({
|
|
page,
|
|
selector: {
|
|
selector: "[id=test-field-type]",
|
|
nth: 0,
|
|
},
|
|
optionText: question.type,
|
|
});
|
|
}
|
|
|
|
if (question.name !== undefined) {
|
|
await page.fill('[name="name"]', question.name);
|
|
}
|
|
|
|
if (question.label !== undefined) {
|
|
await page.fill('[name="label"]', question.label);
|
|
}
|
|
|
|
if (question.placeholder !== undefined) {
|
|
await page.fill('[name="placeholder"]', question.placeholder);
|
|
}
|
|
|
|
if (question.required !== undefined) {
|
|
// await page.fill('[name="name"]', question.required);
|
|
}
|
|
|
|
await page.click('[data-testid="field-add-save"]');
|
|
await saveEventType(page);
|
|
}
|
|
|
|
async function expectErrorToBeThereFor({ page, name }: { page: Page; name: string }) {
|
|
await expect(page.locator(`[data-testid=error-message-${name}]`)).toHaveCount(1);
|
|
// TODO: We should either verify the error message or error code in the test so we know that the correct error is shown
|
|
// Checking for the error message isn't well maintainable as translation can change and we might want to verify in non english language as well.
|
|
}
|
|
|
|
/**
|
|
* Opens a fresh preview window and runs the callback on it giving it the preview tab's `page`
|
|
*/
|
|
async function doOnFreshPreview(
|
|
page: Page,
|
|
context: PlaywrightTestArgs["context"],
|
|
callback: (page: Page) => Promise<void>,
|
|
persistTab = false
|
|
) {
|
|
const previewTabPage = await openBookingFormInPreviewTab(context, page);
|
|
await callback(previewTabPage);
|
|
if (!persistTab) {
|
|
await previewTabPage.close();
|
|
}
|
|
return previewTabPage;
|
|
}
|
|
|
|
async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) {
|
|
await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="toggle-field"]').click();
|
|
await saveEventType(page);
|
|
}
|
|
|
|
async function toggleQuestionRequireStatusAndSave({
|
|
required,
|
|
name,
|
|
page,
|
|
}: {
|
|
required: boolean;
|
|
name: string;
|
|
page: Page;
|
|
}) {
|
|
await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="edit-field-action"]').click();
|
|
await page
|
|
.locator('[data-testid="edit-field-dialog"]')
|
|
.locator('[data-testid="field-required"] button')
|
|
.locator(`text=${required ? "Yes" : "No"}`)
|
|
.click();
|
|
await page.locator('[data-testid="field-add-save"]').click();
|
|
await saveEventType(page);
|
|
}
|
|
|
|
async function createAndLoginUserWithEventTypes({
|
|
users,
|
|
page,
|
|
}: {
|
|
users: ReturnType<typeof createUsersFixture>;
|
|
page: Page;
|
|
}) {
|
|
const user = await users.create(null, {
|
|
hasTeam: true,
|
|
});
|
|
await user.apiLogin();
|
|
await page.goto("/event-types");
|
|
// We wait until loading is finished
|
|
await page.waitForSelector('[data-testid="event-types"]');
|
|
return user;
|
|
}
|
|
|
|
async function rescheduleFromTheLinkOnPage({ page }: { page: Page }) {
|
|
await page.locator('[data-testid="reschedule-link"]').click();
|
|
await page.waitForLoadState();
|
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
|
await page.click('[data-testid="confirm-reschedule-button"]');
|
|
}
|
|
|
|
async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) {
|
|
const previewTabPromise = context.waitForEvent("page");
|
|
await page.locator('[data-testid="preview-button"]').click();
|
|
const previewTabPage = await previewTabPromise;
|
|
await previewTabPage.waitForLoadState();
|
|
await selectFirstAvailableTimeSlotNextMonth(previewTabPage);
|
|
return previewTabPage;
|
|
}
|
|
|
|
async function saveEventType(page: Page) {
|
|
await page.locator("[data-testid=update-eventtype]").click();
|
|
}
|
|
|
|
async function addWebhook(
|
|
user?: Awaited<ReturnType<typeof createAndLoginUserWithEventTypes>>,
|
|
teamId?: number | null
|
|
) {
|
|
const webhookReceiver = createHttpServer();
|
|
|
|
const data: {
|
|
id: string;
|
|
subscriberUrl: string;
|
|
eventTriggers: WebhookTriggerEvents[];
|
|
userId?: number;
|
|
teamId?: number;
|
|
} = {
|
|
id: uuid(),
|
|
subscriberUrl: webhookReceiver.url,
|
|
eventTriggers: [
|
|
WebhookTriggerEvents.BOOKING_CREATED,
|
|
WebhookTriggerEvents.BOOKING_CANCELLED,
|
|
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
|
],
|
|
};
|
|
|
|
if (teamId) {
|
|
data.teamId = teamId;
|
|
} else if (user) {
|
|
data.userId = user.id;
|
|
}
|
|
|
|
await prisma.webhook.create({ data });
|
|
|
|
return webhookReceiver;
|
|
}
|
|
|
|
async function expectWebhookToBeCalled(
|
|
webhookReceiver: Awaited<ReturnType<typeof addWebhook>>,
|
|
expectedBody: {
|
|
triggerEvent: WebhookTriggerEvents;
|
|
payload: Omit<Partial<CalendarEvent>, "attendees"> & {
|
|
attendees: Partial<CalendarEvent["attendees"][number]>[];
|
|
};
|
|
}
|
|
) {
|
|
await waitFor(() => {
|
|
expect(webhookReceiver.requestList.length).toBe(1);
|
|
});
|
|
const [request] = webhookReceiver.requestList;
|
|
|
|
const body = request.body;
|
|
|
|
expect(body).toMatchObject(expectedBody);
|
|
}
|