Skip to content

Commit 4e5d4f6

Browse files
fix: resolve flaky integration tests (calcom#25030)
* fix: resolve flaky org-admin integration tests - Fixed isAdminGuard Prisma query to use explicit 'is' filter for organizationSettings - Fixed async describe with top-level awaits in _get.integration-test.ts - Added global setup in setupVitest.ts to prevent race conditions - Removed duplicate setup logic from individual test files Root cause: Tests were running in parallel with independent beforeAll setups, causing race conditions where organizationSettings weren't created before tests executed. The async describe with top-level awaits made this worse by executing queries before beforeAll hooks ran. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: move org-admin setup to integration-only setup file The global setup in setupVitest.ts was running for ALL test workspaces (including unit tests), causing ECONNREFUSED errors because unit tests don't have database access. Changes: - Created setupVitest.integration.ts with org-admin seeding logic - Removed database seeding from setupVitest.ts - Updated vitest.workspace.ts to use integration-only setup file - Added DATABASE_URL guard to prevent errors when DB is unavailable This fixes the unit test failures while preserving the fix for flaky integration tests. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: use globalSetup instead of setupFiles for org-admin seeding The previous fix using setupFiles didn't work because setupFiles run AFTER test modules are evaluated. This meant any top-level Prisma queries in test files would execute before the org-admin seeding. Changes: - Moved org-admin seeding to tests/integration/global-setup.ts - Updated vitest.workspace.ts to use globalSetup for IntegrationTests - globalSetup runs BEFORE any test modules are loaded, ensuring org settings exist before tests execute - Added teardown function to properly disconnect Prisma after tests This ensures org-admin state is seeded once before all integration tests run, eliminating the race condition and ensuring tests have the correct database state. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * debug: add logging to globalSetup to diagnose why tests are failing Added console.log statements throughout the globalSetup to verify: - Whether the globalSetup is running at all - Whether DATABASE_URL is available - Whether the org teams are found in the database - Whether the upserts are executing successfully This will help diagnose why the integration tests are still failing with org-admin not being detected. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: use absolute path for globalSetup in vitest.workspace.ts Changed from relative path 'tests/integration/global-setup.ts' to absolute path using new URL().pathname to ensure Vitest can properly locate and load the globalSetup file. This should fix the issue where the globalSetup wasn't being executed at all (no logs appearing in CI). Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: add serial execution to IntegrationTests workspace Added sequence.concurrent: false to IntegrationTests workspace to eliminate inter-file race conditions while stabilizing org-admin seeding. This ensures tests run one at a time, preventing parallel execution issues that could cause flaky test failures. This is a temporary stabilizer that can be reverted once the globalSetup seeding is confirmed working. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * refactor: use TeamRepository in globalSetup to follow architectural rule Refactored globalSetup to use TeamRepository instead of direct Prisma access, following the 'No prisma outside of repositories' architectural rule. Changes: - Created TeamRepository class with methods for finding organizations and upserting organization settings - Updated globalSetup to use TeamRepository.withGlobalPrisma() - Removed direct Prisma imports from globalSetup This ensures proper separation of concerns and follows the repository pattern established in the codebase. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: use relative path import for TeamRepository in globalSetup Changed from package-scoped import '@calcom/lib/server/repository/team' to relative path import '../../packages/lib/server/repository/team' to fix module resolution issue. Added try/catch with logging around the import to surface any remaining resolution issues in CI logs. This should allow the globalSetup to execute properly and seed org-admin state before tests run. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * debug: add membership logging to globalSetup to diagnose test failures Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: ensure owner1-acme membership exists in globalSetup Root cause: CI database snapshot doesn't include the owner1-acme OWNER membership that exists in the current seed file, because cache-db action's cache key doesn't include scripts/seed.ts. Solution: Add ensureMembership method to TeamRepository and call it in globalSetup to ensure the owner1-acme user has an accepted OWNER membership in the Acme org before tests run. This fixes the 5 failing org-admin integration tests that depend on this membership. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: ensure all 10 member{0-9}-acme users exist in globalSetup Add ensureUser method to TeamRepository to create users if they don't exist. Update ensureMembership to accept MEMBER role in addition to OWNER and ADMIN. Ensure all 10 member{0-9}-acme users are created with MEMBER role and accepted: true in the Acme org. This should fix the remaining 4 failing tests that expect multiple org members to exist. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: use upsert instead of create in ensureUser to avoid unique constraint violations The ensureUser method was using create which could fail if a user with that email already exists. Switch to upsert to make the operation idempotent and avoid P2002 unique constraint errors. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * refactor: clean up debug code and move test repository to proper location - Remove all console.log debug statements from global-setup.ts - Remove serial execution from IntegrationTests workspace (restore parallel execution) - Move TeamRepository to tests/lib/test-team-repository.ts and rename to TestTeamRepository - Keep all actual fixes: isAdmin Prisma query fix, ensureUser/ensureMembership methods, globalSetup seeding Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 83488f0 commit 4e5d4f6

8 files changed

Lines changed: 215 additions & 124 deletions

File tree

apps/api/v1/lib/utils/isAdmin.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export const isAdminGuard = async (req: NextApiRequest) => {
1919
team: {
2020
isOrganization: true,
2121
organizationSettings: {
22-
isAdminAPIEnabled: true,
22+
is: {
23+
isAdminAPIEnabled: true,
24+
},
2325
},
2426
},
2527
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],

apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Request, Response } from "express";
22
import type { NextApiRequest, NextApiResponse } from "next";
33
import { createMocks } from "node-mocks-http";
4-
import { describe, it, expect, beforeAll } from "vitest";
4+
import { describe, it, expect } from "vitest";
55

66
import prisma from "@calcom/prisma";
77

@@ -11,30 +11,6 @@ type CustomNextApiRequest = NextApiRequest & Request;
1111
type CustomNextApiResponse = NextApiResponse & Response;
1212

1313
describe("PATCH /api/bookings", () => {
14-
beforeAll(async () => {
15-
const acmeOrg = await prisma.team.findFirst({
16-
where: {
17-
slug: "acme",
18-
isOrganization: true,
19-
},
20-
});
21-
22-
if (acmeOrg) {
23-
await prisma.organizationSettings.upsert({
24-
where: {
25-
organizationId: acmeOrg.id,
26-
},
27-
update: {
28-
isAdminAPIEnabled: true,
29-
},
30-
create: {
31-
organizationId: acmeOrg.id,
32-
orgAutoAcceptEmail: "acme.com",
33-
isAdminAPIEnabled: true,
34-
},
35-
});
36-
}
37-
});
3814
it("Returns 403 when user has no permission to the booking", async () => {
3915
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
4016
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });

apps/api/v1/test/lib/bookings/_get.integration-test.ts

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,33 +16,14 @@ const DefaultPagination = {
1616
skip: 0,
1717
};
1818

19-
describe("GET /api/bookings", async () => {
20-
beforeAll(async () => {
21-
const acmeOrg = await prisma.team.findFirst({
22-
where: {
23-
slug: "acme",
24-
isOrganization: true,
25-
},
26-
});
19+
describe("GET /api/bookings", () => {
20+
let proUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
21+
let proUserBooking: Awaited<ReturnType<typeof prisma.booking.findFirstOrThrow>>;
2722

28-
if (acmeOrg) {
29-
await prisma.organizationSettings.upsert({
30-
where: {
31-
organizationId: acmeOrg.id,
32-
},
33-
update: {
34-
isAdminAPIEnabled: true,
35-
},
36-
create: {
37-
organizationId: acmeOrg.id,
38-
orgAutoAcceptEmail: "acme.com",
39-
isAdminAPIEnabled: true,
40-
},
41-
});
42-
}
23+
beforeAll(async () => {
24+
proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
25+
proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
4326
});
44-
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
45-
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
4627

4728
it("Does not return bookings of other users when user has no permission", async () => {
4829
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });

apps/api/v1/test/lib/utils/isAdmin.integration-test.ts

Lines changed: 1 addition & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Request, Response } from "express";
22
import type { NextApiRequest, NextApiResponse } from "next";
33
import { createMocks } from "node-mocks-http";
4-
import { describe, it, expect, beforeAll } from "vitest";
4+
import { describe, it, expect } from "vitest";
55

66
import prisma from "@calcom/prisma";
77

@@ -12,53 +12,6 @@ type CustomNextApiRequest = NextApiRequest & Request;
1212
type CustomNextApiResponse = NextApiResponse & Response;
1313

1414
describe("isAdmin guard", () => {
15-
beforeAll(async () => {
16-
const acmeOrg = await prisma.team.findFirst({
17-
where: {
18-
slug: "acme",
19-
isOrganization: true,
20-
},
21-
});
22-
23-
if (acmeOrg) {
24-
await prisma.organizationSettings.upsert({
25-
where: {
26-
organizationId: acmeOrg.id,
27-
},
28-
update: {
29-
isAdminAPIEnabled: true,
30-
},
31-
create: {
32-
organizationId: acmeOrg.id,
33-
orgAutoAcceptEmail: "acme.com",
34-
isAdminAPIEnabled: true,
35-
},
36-
});
37-
}
38-
39-
const dunderOrg = await prisma.team.findFirst({
40-
where: {
41-
slug: "dunder-mifflin",
42-
isOrganization: true,
43-
},
44-
});
45-
46-
if (dunderOrg) {
47-
await prisma.organizationSettings.upsert({
48-
where: {
49-
organizationId: dunderOrg.id,
50-
},
51-
update: {
52-
isAdminAPIEnabled: false,
53-
},
54-
create: {
55-
organizationId: dunderOrg.id,
56-
orgAutoAcceptEmail: "dunder-mifflin.com",
57-
isAdminAPIEnabled: false,
58-
},
59-
});
60-
}
61-
});
6215
it("Returns false when user does not exist in the system", async () => {
6316
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
6417
method: "POST",

apps/api/v1/test/lib/utils/retrieveScopedAccessibleUsers.integration-test.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeAll } from "vitest";
1+
import { describe, it, expect } from "vitest";
22

33
import prisma from "@calcom/prisma";
44

@@ -8,30 +8,6 @@ import {
88
} from "../../../lib/utils/retrieveScopedAccessibleUsers";
99

1010
describe("retrieveScopedAccessibleUsers tests", () => {
11-
beforeAll(async () => {
12-
const acmeOrg = await prisma.team.findFirst({
13-
where: {
14-
slug: "acme",
15-
isOrganization: true,
16-
},
17-
});
18-
19-
if (acmeOrg) {
20-
await prisma.organizationSettings.upsert({
21-
where: {
22-
organizationId: acmeOrg.id,
23-
},
24-
update: {
25-
isAdminAPIEnabled: true,
26-
},
27-
create: {
28-
organizationId: acmeOrg.id,
29-
orgAutoAcceptEmail: "acme.com",
30-
isAdminAPIEnabled: true,
31-
},
32-
});
33-
}
34-
});
3511
describe("getAccessibleUsers", () => {
3612
it("Does not return members when only admin user ID is supplied", async () => {
3713
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });

tests/integration/global-setup.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Global setup for integration tests
3+
* Runs once before all integration tests to seed org-admin state
4+
*/
5+
export default async function globalSetup() {
6+
const module = await import("../lib/test-team-repository");
7+
const TestTeamRepository = module.TestTeamRepository;
8+
const teamRepo = await TestTeamRepository.withGlobalPrisma();
9+
10+
const acmeOrg = await teamRepo.findOrganizationBySlug("acme");
11+
12+
if (acmeOrg) {
13+
await teamRepo.upsertOrganizationSettings({
14+
organizationId: acmeOrg.id,
15+
isAdminAPIEnabled: true,
16+
orgAutoAcceptEmail: "acme.com",
17+
});
18+
19+
await teamRepo.ensureMembership({
20+
userEmail: "owner1-acme@example.com",
21+
organizationId: acmeOrg.id,
22+
role: "OWNER",
23+
accepted: true,
24+
});
25+
26+
for (let i = 0; i < 10; i++) {
27+
const memberEmail = `member${i}-acme@example.com`;
28+
const memberUsername = `member${i}-acme`;
29+
const memberName = `Member ${i}`;
30+
31+
await teamRepo.ensureUser({
32+
email: memberEmail,
33+
username: memberUsername,
34+
name: memberName,
35+
});
36+
37+
await teamRepo.ensureMembership({
38+
userEmail: memberEmail,
39+
organizationId: acmeOrg.id,
40+
role: "MEMBER",
41+
accepted: true,
42+
});
43+
}
44+
}
45+
46+
const dunderOrg = await teamRepo.findOrganizationBySlug("dunder-mifflin");
47+
48+
if (dunderOrg) {
49+
await teamRepo.upsertOrganizationSettings({
50+
organizationId: dunderOrg.id,
51+
isAdminAPIEnabled: false,
52+
orgAutoAcceptEmail: "dunder-mifflin.com",
53+
});
54+
}
55+
56+
return async () => {
57+
};
58+
}

tests/lib/test-team-repository.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { PrismaClient } from "@calcom/prisma";
2+
3+
export class TestTeamRepository {
4+
constructor(private prismaClient: PrismaClient) {}
5+
6+
static async withGlobalPrisma() {
7+
return new TestTeamRepository((await import("@calcom/prisma")).prisma);
8+
}
9+
10+
async findOrganizationBySlug(slug: string) {
11+
return this.prismaClient.team.findFirst({
12+
where: {
13+
slug,
14+
isOrganization: true,
15+
},
16+
select: {
17+
id: true,
18+
slug: true,
19+
name: true,
20+
},
21+
});
22+
}
23+
24+
async upsertOrganizationSettings({
25+
organizationId,
26+
isAdminAPIEnabled,
27+
orgAutoAcceptEmail,
28+
}: {
29+
organizationId: number;
30+
isAdminAPIEnabled: boolean;
31+
orgAutoAcceptEmail?: string;
32+
}) {
33+
return this.prismaClient.organizationSettings.upsert({
34+
where: {
35+
organizationId,
36+
},
37+
update: {
38+
isAdminAPIEnabled,
39+
},
40+
create: {
41+
organizationId,
42+
orgAutoAcceptEmail: orgAutoAcceptEmail || "",
43+
isAdminAPIEnabled,
44+
},
45+
});
46+
}
47+
48+
async findUserMembershipsInOrg({
49+
userEmail,
50+
organizationId,
51+
}: {
52+
userEmail: string;
53+
organizationId: number;
54+
}) {
55+
return this.prismaClient.membership.findMany({
56+
where: {
57+
user: {
58+
email: userEmail,
59+
},
60+
teamId: organizationId,
61+
},
62+
select: {
63+
id: true,
64+
role: true,
65+
accepted: true,
66+
user: {
67+
select: {
68+
id: true,
69+
email: true,
70+
},
71+
},
72+
},
73+
});
74+
}
75+
76+
async ensureUser({
77+
email,
78+
username,
79+
name,
80+
}: {
81+
email: string;
82+
username: string;
83+
name: string;
84+
}) {
85+
return this.prismaClient.user.upsert({
86+
where: { email },
87+
update: {
88+
name,
89+
},
90+
create: {
91+
email,
92+
username,
93+
name,
94+
emailVerified: new Date(),
95+
},
96+
});
97+
}
98+
99+
async ensureMembership({
100+
userEmail,
101+
organizationId,
102+
role,
103+
accepted,
104+
}: {
105+
userEmail: string;
106+
organizationId: number;
107+
role: "OWNER" | "ADMIN" | "MEMBER";
108+
accepted: boolean;
109+
}) {
110+
const user = await this.prismaClient.user.findFirst({
111+
where: { email: userEmail },
112+
});
113+
114+
if (!user) {
115+
throw new Error(`User with email ${userEmail} not found`);
116+
}
117+
118+
const existingMembership = await this.prismaClient.membership.findFirst({
119+
where: {
120+
userId: user.id,
121+
teamId: organizationId,
122+
},
123+
});
124+
125+
if (existingMembership) {
126+
return this.prismaClient.membership.update({
127+
where: { id: existingMembership.id },
128+
data: {
129+
role,
130+
accepted,
131+
},
132+
});
133+
}
134+
135+
return this.prismaClient.membership.create({
136+
data: {
137+
userId: user.id,
138+
teamId: organizationId,
139+
role,
140+
accepted,
141+
},
142+
});
143+
}
144+
}

0 commit comments

Comments
 (0)