Files
calendar/apps/web/playwright/insights.e2e.ts
T
Eunjae LeeGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
6479adf14e fix: integer to text comparison in routing insights query (#25019)
* fix: integer to text comparison in routing insights query

Add explicit integer array cast to prevent PostgreSQL type mismatch error when comparing bookingUserId (integer) with user_id array values in getRoutedToPerPeriodData query

* add e2e tests

* refactor: remove LoadingInsight component and handle loading in ChartCard

- Enhanced ChartCard to render default loading UI when isPending is true
- Replaced all LoadingInsight usages with ChartCard that accepts isPending/isError props
- Removed LoadingInsight component and updated exports
- Updated 16 chart components to use the new pattern
- ChartCard now shows spinner and skeleton title during loading state

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: make children prop optional in ChartCard when isPending is true

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: remove unused loadingState prop from ChartCard

- Remove loadingState prop and ChartLoadingState type export
- Simplify computedLoadingState to derive state only from isPending/isError
- No functional changes - loadingState was not being used by any components
- data-loading-state attribute behavior remains unchanged for E2E tests

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: remove duplicate isPending early returns from chart components

- Update all 16 chart components to use single ChartCard return pattern
- Gate children rendering with !isPending && isSuccess && data checks
- Prevents data processing code from executing during loading state
- Improves code consistency and maintainability across all charts

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: remove redundant !isPending check from chart conditionals

- Simplify conditional rendering to use just 'isSuccess && data' or 'isSuccess'
- In TanStack Query, isSuccess and isPending are mutually exclusive
- The !isPending check was redundant since isSuccess already implies !isPending
- Applied to all 16 chart components for consistency
- Components with safe defaults (data ?? []) use just 'isSuccess'
- Components requiring data check use 'isSuccess && data'

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* revert the mistake

* apply feedback

* apply feedback

* fix e2e

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-01 15:07:57 +01:00

353 lines
10 KiB
TypeScript

import { expect } from "@playwright/test";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { randomString } from "@calcom/lib/random";
import { prisma } from "@calcom/prisma";
import { clearFilters, applySelectFilter } from "./filter-helpers";
import { test } from "./lib/fixtures";
import { createAllPermissionsArray, enablePBACForTeam } from "./lib/test-helpers/pbac";
test.describe.configure({ mode: "parallel" });
const createTeamsAndMembership = async (userIdOne: number, userIdTwo: number) => {
const teamOne = await prisma.team.create({
data: {
name: "test-insights",
slug: `test-insights-${Date.now()}-${randomString(5)}}`,
},
});
const teamTwo = await prisma.team.create({
data: {
name: "test-insights-2",
slug: `test-insights-2-${Date.now()}-${randomString(5)}}`,
},
});
if (!userIdOne || !userIdTwo || !teamOne || !teamTwo) {
throw new Error("Failed to create test data");
}
// create memberships
await prisma.membership.create({
data: {
userId: userIdOne,
teamId: teamOne.id,
accepted: true,
role: "ADMIN",
},
});
await prisma.membership.create({
data: {
teamId: teamTwo.id,
userId: userIdOne,
accepted: true,
role: "ADMIN",
},
});
await prisma.membership.create({
data: {
teamId: teamOne.id,
userId: userIdTwo,
accepted: true,
role: "MEMBER",
},
});
await prisma.membership.create({
data: {
teamId: teamTwo.id,
userId: userIdTwo,
accepted: true,
role: "MEMBER",
},
});
return { teamOne, teamTwo };
};
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test.describe("Insights", async () => {
test("should be able to go to insights as admins", async ({ page, users }) => {
const user = await users.create();
const userTwo = await users.create();
await createTeamsAndMembership(user.id, userTwo.id);
await user.apiLogin();
// go to insights page
await page.goto("/insights");
await page.locator('[data-testid^="insights-filters-false-"]').waitFor();
});
test("should be able to go to insights as members", async ({ page, users }) => {
const user = await users.create();
const userTwo = await users.create();
await userTwo.apiLogin();
await createTeamsAndMembership(user.id, userTwo.id);
// go to insights page
await page.goto("/insights");
await page.locator('[data-testid^="insights-filters-false-"]').waitFor();
});
test("team select filter should have 2 teams and your account option only as member", async ({
page,
users,
}) => {
const user = await users.create();
const userTwo = await users.create();
await user.apiLogin();
await createTeamsAndMembership(user.id, userTwo.id);
// go to insights page
await page.goto("/insights");
await page.locator(`[data-testid=insights-filters-false-undefined-${user.id}]`).waitFor();
await page.getByTestId("dashboard-shell").getByText("Your account").click();
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
await expect(await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })).toHaveCount(
0
);
await expect(
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "Your account" })
).toHaveCount(1);
});
test("Insights Organization should have isAll option true", async ({ users, page }) => {
const owner = await users.create(undefined, {
hasTeam: true,
isUnpublished: true,
isOrg: true,
hasSubteam: true,
});
await owner.apiLogin();
await page.goto("/insights");
await page.locator('[data-testid^="insights-filters-true-"]').waitFor();
await page.locator('[data-testid^="insights-filters-true-"]').getByText("All").click();
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
await expect(
await page
.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })
.locator('input[type="checkbox"]')
).toBeChecked();
});
test("should not have all option as admin in non-org", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
await page.getByTestId("dashboard-shell").getByText("Your account").click();
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
await expect(await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })).toHaveCount(
0
);
});
test("should be able to switch between teams and self profile for insights", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
await page.getByTestId("dashboard-shell").getByText("Your account").click();
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
// switch to self profile
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "Your account" }).click();
// switch to team 1
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "test-insights" }).first().click();
// switch to team 2
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "test-insights-2" }).click();
});
test("should be able to switch between memberUsers", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
// Choose a team
await page.getByTestId("dashboard-shell").getByText("Your account").click();
await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click();
await page.keyboard.press("Escape");
await applySelectFilter(page, "userId", member.username || "");
await clearFilters(page);
await expect(page).not.toHaveURL(/[?&]userId=/);
});
test("should test download button", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
const downloadPromise = page.waitForEvent("download");
// Expect download button to be visible
await expect(page.locator("text=Download")).toBeVisible();
// Click on Download button
await page.getByText("Download").click();
// Expect as csv option to be visible
await expect(page.locator("text=as CSV")).toBeVisible();
// Start waiting for download before clicking. Note no await.
await page.getByText("as CSV").click();
const download = await downloadPromise;
// Wait for the download process to complete and save the downloaded file somewhere.
await download.saveAs("./" + "test-insights.csv");
});
test("should render all ChartCard components with expected titles", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
const expectedChartTitles = [
"Events",
"Performance",
"Event Trends",
"Bookings by Hour",
"Average Event Duration",
"Most Bookings Scheduled",
"Least Bookings Scheduled",
"Most Bookings Completed",
"Least Bookings Completed",
"Most Cancelled",
"Most No-Show",
"Recent No-Show Guests",
"Highest Rated",
"Lowest Rated",
"Recent Ratings",
"Popular Events",
];
for (const title of expectedChartTitles) {
const chartCard = page
.locator("[data-testid='chart-card'] h2")
.filter({ hasText: new RegExp(`^${title}$`) });
await expect(chartCard).toBeVisible();
}
});
test("should be able to access insights page with custom role lacking insights.read permission", async ({
page,
users,
}) => {
const owner = await users.create(undefined, {
hasTeam: true,
isUnpublished: true,
isOrg: true,
});
const userOne = await users.create();
const userTwo = await users.create();
const { teamOne } = await createTeamsAndMembership(userOne.id, userTwo.id);
const orgMembership = await owner.getOrgMembership();
const orgId = orgMembership.team.id;
await enablePBACForTeam(orgId);
await enablePBACForTeam(teamOne.id);
const permissions = createAllPermissionsArray().filter(
({ resource, action }) => !(resource === "insights" && action === "read")
);
const customRole = await prisma.role.create({
data: {
id: `e2e_no_insights_${orgId}_${Date.now()}`,
name: "E2E Role Without Insights",
description: "E2E role for testing - has all permissions except insights.read",
color: "#dc2626",
teamId: orgId,
type: "CUSTOM",
permissions: {
create: permissions,
},
},
});
await prisma.membership.update({
where: {
userId_teamId: {
userId: userOne.id,
teamId: teamOne.id,
},
},
data: {
customRoleId: customRole.id,
},
});
const featuresRepository = new FeaturesRepository(prisma);
const isPBACEnabled = await featuresRepository.checkIfTeamHasFeature(orgId, "pbac");
expect(isPBACEnabled).toBe(true);
const permissionService = new PermissionCheckService();
const hasPermission = await permissionService.checkPermission({
userId: userOne.id,
teamId: teamOne.id,
permission: "insights.read",
fallbackRoles: [],
});
expect(hasPermission).toBe(false);
await userOne.apiLogin();
await page.goto("/insights");
// Verify the user can access the insights page
await page.locator('[data-testid^="insights-filters-"]').waitFor();
expect(page.url()).toContain("/insights");
});
});