feat: add comprehensive validation tests for event-types/[id] GET endpoint (#23809)

* feat: add comprehensive validation tests for event-types/[id] GET endpoint

- Add integration tests for complex validation scenarios
- Test locations, booking fields, metadata, and seats validation
- Test edge cases that could cause validation mismatches between validators and DB results
- Ensure API response structure matches schemaEventTypeReadPublic schema
- Successfully catch validation discrepancies like missing displayLocationPublicly and assignAllTeamMembers fields

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: add explicit type annotations for booking fields find callbacks

- Fix TypeScript errors on lines 388 and 395
- Provide proper type annotations for find method callback parameters
- Replace any[] with specific { label: string; value: string }[] type
- Ensure CI type checking passes

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* feat: convert event-types integration test to use real database

- Replace prismaMock with real Prisma client operations
- Create actual database records for comprehensive test scenarios
- Add proper setup/teardown with unique timestamps for test isolation
- Maintain all existing validation test coverage
- Follow Cal.com integration test patterns for database operations

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* feat: restore original mock-based test alongside integration test

- Restored original _get.test.ts with prismaMock for unit testing
- Kept _get.integration.test.ts with real database for integration testing
- Both tests provide complementary coverage: mocks for fast unit tests, real DB for validation testing
- Fixed type annotations in find callbacks to maintain type safety

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: rename integration test to use proper naming convention

- Rename _get.integration.test.ts to _get.integration-test.ts
- Follows Cal.com vitest workspace pattern for integration tests
- Ensures integration test runs in proper CI workflow with database setup

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: update tests to use handler return value instead of res._getData()

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: add proper type assertions for locations and bookingFields in tests

Co-Authored-By: unknown <>

* fix: create schedule for test user to fix foreign key constraint in integration tests

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
This commit is contained in:
Alex van Andel
2026-01-12 12:01:43 +00:00
committed by GitHub
co-authored by keith@cal.com unknown Devin AI
parent b6df61d287
commit 6fb42b63e0
2 changed files with 894 additions and 0 deletions
@@ -0,0 +1,545 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test, beforeAll, afterAll } from "vitest";
import { prisma } from "@calcom/prisma";
import type { User, Team, EventType, Schedule } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import handler from "../../../../pages/api/event-types/[id]/_get";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("GET /api/event-types/[id]", () => {
let testUser: User;
let adminUser: User;
let teamUser: User;
let testTeam: Team;
let testSchedule: Schedule;
let complexEventType: EventType;
let seatsEventType: EventType;
let maxSeatsEventType: EventType;
let nullSeatsEventType: EventType;
let customFieldsEventType: EventType;
let teamEventType: EventType;
let metadataEventType: EventType;
beforeAll(async () => {
const timestamp = Date.now();
const createTestUser = prisma.user.create({
data: {
username: `test-user-${timestamp}`,
name: "Test User",
email: `test-user-${timestamp}@example.com`,
},
});
const createAdminUser = prisma.user.create({
data: {
username: `admin-user-${timestamp}`,
name: "Admin User",
email: `admin-user-${timestamp}@example.com`,
},
});
const createTeamUser = prisma.user.create({
data: {
username: `team-user-${timestamp}`,
name: "Team User",
email: `team-user-${timestamp}@example.com`,
},
});
[testUser, adminUser, teamUser] = await Promise.all([createTestUser, createAdminUser, createTeamUser]);
// Create a schedule for the test user (required for event types with scheduleId)
testSchedule = await prisma.schedule.create({
data: {
name: `Test Schedule ${timestamp}`,
userId: testUser.id,
timeZone: "UTC",
},
});
testTeam = await prisma.team.create({
data: {
name: `Test Team ${timestamp}`,
slug: `test-team-${timestamp}`,
members: {
createMany: {
data: [
{
userId: teamUser.id,
role: MembershipRole.MEMBER,
accepted: true,
},
],
},
},
},
});
const createComplexEventType = prisma.eventType.create({
data: {
title: "Complex Event Type",
slug: `complex-event-${timestamp}`,
length: 60,
userId: testUser.id,
scheduleId: testSchedule.id,
locations: [
{
type: "integrations:zoom",
link: "https://zoom.us/j/123456789",
displayLocationPublicly: true,
},
{
type: "attendeeInPerson",
address: "123 Main St, City, State 12345",
displayLocationPublicly: false,
},
{
type: "phone",
hostPhoneNumber: "+1234567890",
},
],
bookingFields: [
{
name: "name",
type: "name",
required: true,
editable: "system",
defaultLabel: "your_name",
},
{
name: "email",
type: "email",
required: true,
editable: "system-but-optional",
defaultLabel: "email_address",
},
{
name: "customField",
type: "text",
required: false,
editable: "user",
label: "Custom Question",
placeholder: "Enter your answer",
},
],
metadata: {
additionalNotesRequired: true,
},
},
});
const createSeatsEventType = prisma.eventType.create({
data: {
title: "Seats Event Type",
slug: `seats-event-${timestamp}`,
length: 60,
userId: testUser.id,
scheduleId: testSchedule.id,
seatsPerTimeSlot: 10,
seatsShowAttendees: true,
seatsShowAvailabilityCount: false,
},
});
const createMaxSeatsEventType = prisma.eventType.create({
data: {
title: "Max Seats Event Type",
slug: `max-seats-event-${timestamp}`,
length: 60,
userId: testUser.id,
scheduleId: testSchedule.id,
seatsPerTimeSlot: 1000,
seatsShowAttendees: false,
seatsShowAvailabilityCount: true,
},
});
const createNullSeatsEventType = prisma.eventType.create({
data: {
title: "Null Seats Event Type",
slug: `null-seats-event-${timestamp}`,
length: 60,
userId: testUser.id,
scheduleId: testSchedule.id,
seatsPerTimeSlot: null,
seatsShowAttendees: null,
seatsShowAvailabilityCount: null,
},
});
const createCustomFieldsEventType = prisma.eventType.create({
data: {
title: "Custom Fields Event Type",
slug: `custom-fields-event-${timestamp}`,
length: 60,
userId: testUser.id,
scheduleId: testSchedule.id,
bookingFields: [
{
name: "name",
type: "name",
required: true,
editable: "system",
defaultLabel: "your_name",
variant: "fullName",
},
{
name: "customSelect",
type: "select",
required: true,
editable: "user",
label: "Preferred Time",
options: [
{ label: "Morning", value: "morning" },
{ label: "Afternoon", value: "afternoon" },
{ label: "Evening", value: "evening" },
],
},
{
name: "customTextarea",
type: "textarea",
required: false,
editable: "user",
label: "Additional Information",
placeholder: "Please provide any additional details",
maxLength: 500,
},
],
},
});
const createTeamEventType = prisma.eventType.create({
data: {
title: "Team Event Type",
slug: `team-event-${timestamp}`,
length: 60,
teamId: testTeam.id,
schedulingType: "COLLECTIVE",
assignAllTeamMembers: true,
},
});
const createMetadataEventType = prisma.eventType.create({
data: {
title: "Metadata Event Type",
slug: `metadata-event-${timestamp}`,
length: 60,
userId: testUser.id,
scheduleId: testSchedule.id,
metadata: {
additionalNotesRequired: true,
disableStandardEmails: false,
requiresConfirmationThreshold: {
time: 30,
unit: "minutes",
},
apps: {
stripe: {
price: 2500,
currency: "usd",
},
},
},
recurringEvent: {
freq: 2,
count: 12,
interval: 1,
},
bookingLimits: {
PER_DAY: 3,
PER_WEEK: 10,
},
durationLimits: {
PER_DAY: 480,
},
},
});
[
complexEventType,
seatsEventType,
maxSeatsEventType,
nullSeatsEventType,
customFieldsEventType,
teamEventType,
metadataEventType,
] = await Promise.all([
createComplexEventType,
createSeatsEventType,
createMaxSeatsEventType,
createNullSeatsEventType,
createCustomFieldsEventType,
createTeamEventType,
createMetadataEventType,
]);
});
afterAll(async () => {
await prisma.eventType.deleteMany({
where: {
id: {
in: [
complexEventType.id,
seatsEventType.id,
maxSeatsEventType.id,
nullSeatsEventType.id,
customFieldsEventType.id,
teamEventType.id,
metadataEventType.id,
],
},
},
});
await prisma.team.delete({
where: { id: testTeam.id },
});
await prisma.schedule.delete({
where: { id: testSchedule.id },
});
await prisma.user.deleteMany({
where: {
id: {
in: [testUser.id, adminUser.id, teamUser.id],
},
},
});
});
describe("Errors", () => {
test("Returns 403 if user not admin/team member/event owner", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: complexEventType.id,
},
});
req.userId = adminUser.id;
await expect(handler(req)).rejects.toThrowError(
expect.objectContaining({
statusCode: 403,
})
);
});
});
describe("Success", async () => {
test("Returns event type if user is admin", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: complexEventType.id,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUser.id;
const responseData = await handler(req);
expect(responseData.event_type.id).toEqual(complexEventType.id);
});
test("Returns event type if user is in team associated with event type", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: teamEventType.id,
},
});
req.isSystemWideAdmin = false;
req.userId = teamUser.id;
const responseData = await handler(req);
expect(responseData.event_type.id).toEqual(teamEventType.id);
});
test("Returns event type if user is the event type owner", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: complexEventType.id,
},
});
req.isSystemWideAdmin = false;
req.userId = testUser.id;
const responseData = await handler(req);
expect(responseData.event_type.id).toEqual(complexEventType.id);
});
});
describe("Data Validation", () => {
test("Returns properly validated event type with complex locations", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: complexEventType.id,
},
});
req.userId = testUser.id;
const responseData = await handler(req);
expect(responseData.event_type).toBeDefined();
expect(responseData.event_type.id).toBe(complexEventType.id);
expect(responseData.event_type.locations).toHaveLength(3);
expect(responseData.event_type.bookingFields).toHaveLength(3);
expect(responseData.event_type.metadata).toBeDefined();
const locations = responseData.event_type.locations as Array<Record<string, unknown>>;
expect(locations[0]).toMatchObject({
type: "integrations:zoom",
link: "https://zoom.us/j/123456789",
});
const bookingFields = responseData.event_type.bookingFields as Array<Record<string, unknown>>;
expect(bookingFields[0]).toMatchObject({
name: "name",
type: "name",
required: true,
editable: "system",
});
});
test("Returns properly validated event type with seats configuration", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: seatsEventType.id,
},
});
req.userId = testUser.id;
const responseData = await handler(req);
expect(responseData.event_type.seatsPerTimeSlot).toBe(10);
expect(responseData.event_type.seatsShowAttendees).toBe(true);
expect(responseData.event_type.seatsShowAvailabilityCount).toBe(false);
});
test("Returns properly validated event type with maximum seats", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: maxSeatsEventType.id,
},
});
req.userId = testUser.id;
const responseData = await handler(req);
expect(responseData.event_type.seatsPerTimeSlot).toBe(1000);
});
test("Returns properly validated event type with null seats", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: nullSeatsEventType.id,
},
});
req.userId = testUser.id;
const responseData = await handler(req);
expect(responseData.event_type.seatsPerTimeSlot).toBeNull();
expect(responseData.event_type.seatsShowAttendees).toBeNull();
expect(responseData.event_type.seatsShowAvailabilityCount).toBeNull();
});
test("Returns properly validated event type with custom inputs and booking fields", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: customFieldsEventType.id,
},
});
req.userId = testUser.id;
const responseData = await handler(req);
expect(responseData.event_type.bookingFields).toHaveLength(3);
const bookingFields = responseData.event_type.bookingFields as Array<{
name: string;
options?: Array<{ label: string; value: string }>;
maxLength?: number;
}>;
const selectField = bookingFields.find((f) => f.name === "customSelect");
expect(selectField?.options).toHaveLength(3);
expect(selectField?.options?.[0]).toMatchObject({
label: "Morning",
value: "morning",
});
const textareaField = bookingFields.find((f) => f.name === "customTextarea");
expect(textareaField?.maxLength).toBe(500);
});
test("Returns properly validated event type with team configuration", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: teamEventType.id,
},
});
req.isSystemWideAdmin = false;
req.userId = teamUser.id;
const responseData = await handler(req);
expect(responseData.event_type.teamId).toBe(testTeam.id);
expect(responseData.event_type.schedulingType).toBe("COLLECTIVE");
});
test("Returns properly validated event type with complex metadata", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: metadataEventType.id,
},
});
req.userId = testUser.id;
const responseData = await handler(req);
expect(responseData.event_type.metadata).toBeDefined();
expect(responseData.event_type.recurringEvent).toMatchObject({
freq: 2,
count: 12,
interval: 1,
});
expect(responseData.event_type.bookingLimits).toMatchObject({
PER_DAY: 3,
PER_WEEK: 10,
});
expect(responseData.event_type.durationLimits).toMatchObject({
PER_DAY: 480,
});
});
});
});
@@ -165,4 +165,353 @@ describe("GET /api/event-types/[id]", () => {
expect(prismaMock.team.findFirst).not.toHaveBeenCalled();
});
});
describe("Data Validation", () => {
test("Returns properly validated event type with complex locations", async () => {
const eventTypeId = 123456;
const userId = 333333;
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: eventTypeId,
},
});
const complexEventType = buildEventType({
id: eventTypeId,
userId,
scheduleId: 1111,
locations: [
{
type: "integrations:zoom",
link: "https://zoom.us/j/123456789",
displayLocationPublicly: true,
},
{
type: "attendeeInPerson",
address: "123 Main St, City, State 12345",
displayLocationPublicly: false,
},
{
type: "phone",
hostPhoneNumber: "+1234567890",
},
],
bookingFields: [
{
name: "name",
type: "name",
required: true,
editable: "system",
defaultLabel: "your_name",
},
{
name: "email",
type: "email",
required: true,
editable: "system-but-optional",
defaultLabel: "email_address",
},
{
name: "customField",
type: "text",
required: false,
editable: "user",
label: "Custom Question",
placeholder: "Enter your answer",
},
],
metadata: {
additionalNotesRequired: true,
},
});
prismaMock.eventType.findUnique.mockResolvedValue(complexEventType);
req.userId = userId;
const responseData = await handler(req);
expect(responseData.event_type).toBeDefined();
expect(responseData.event_type.id).toBe(eventTypeId);
expect(responseData.event_type.locations).toHaveLength(3);
expect(responseData.event_type.bookingFields).toHaveLength(3);
expect(responseData.event_type.metadata).toBeDefined();
const locations = responseData.event_type.locations as Array<Record<string, unknown>>;
expect(locations[0]).toMatchObject({
type: "integrations:zoom",
link: "https://zoom.us/j/123456789",
});
const bookingFields = responseData.event_type.bookingFields as Array<Record<string, unknown>>;
expect(bookingFields[0]).toMatchObject({
name: "name",
type: "name",
required: true,
editable: "system",
});
});
test("Returns properly validated event type with seats configuration", async () => {
const eventTypeId = 123456;
const userId = 333333;
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: eventTypeId,
},
});
const seatsEventType = buildEventType({
id: eventTypeId,
userId,
scheduleId: 1111,
seatsPerTimeSlot: 10,
seatsShowAttendees: true,
seatsShowAvailabilityCount: false,
});
prismaMock.eventType.findUnique.mockResolvedValue(seatsEventType);
req.userId = userId;
const responseData = await handler(req);
expect(responseData.event_type.seatsPerTimeSlot).toBe(10);
expect(responseData.event_type.seatsShowAttendees).toBe(true);
expect(responseData.event_type.seatsShowAvailabilityCount).toBe(false);
});
test("Returns properly validated event type with maximum seats", async () => {
const eventTypeId = 123456;
const userId = 333333;
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: eventTypeId,
},
});
const maxSeatsEventType = buildEventType({
id: eventTypeId,
userId,
scheduleId: 1111,
seatsPerTimeSlot: 1000,
seatsShowAttendees: false,
seatsShowAvailabilityCount: true,
});
prismaMock.eventType.findUnique.mockResolvedValue(maxSeatsEventType);
req.userId = userId;
const responseData = await handler(req);
expect(responseData.event_type.seatsPerTimeSlot).toBe(1000);
});
test("Returns properly validated event type with null seats", async () => {
const eventTypeId = 123456;
const userId = 333333;
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: eventTypeId,
},
});
const nullSeatsEventType = buildEventType({
id: eventTypeId,
userId,
scheduleId: 1111,
seatsPerTimeSlot: null,
seatsShowAttendees: null,
seatsShowAvailabilityCount: null,
});
prismaMock.eventType.findUnique.mockResolvedValue(nullSeatsEventType);
req.userId = userId;
const responseData = await handler(req);
expect(responseData.event_type.seatsPerTimeSlot).toBeNull();
expect(responseData.event_type.seatsShowAttendees).toBeNull();
expect(responseData.event_type.seatsShowAvailabilityCount).toBeNull();
});
test("Returns properly validated event type with custom inputs and booking fields", async () => {
const eventTypeId = 123456;
const userId = 333333;
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: eventTypeId,
},
});
const customFieldsEventType = buildEventType({
id: eventTypeId,
userId,
scheduleId: 1111,
bookingFields: [
{
name: "name",
type: "name",
required: true,
editable: "system",
defaultLabel: "your_name",
variant: "fullName",
},
{
name: "customSelect",
type: "select",
required: true,
editable: "user",
label: "Preferred Time",
options: [
{ label: "Morning", value: "morning" },
{ label: "Afternoon", value: "afternoon" },
{ label: "Evening", value: "evening" },
],
},
{
name: "customTextarea",
type: "textarea",
required: false,
editable: "user",
label: "Additional Information",
placeholder: "Please provide any additional details",
maxLength: 500,
},
],
});
prismaMock.eventType.findUnique.mockResolvedValue(customFieldsEventType);
req.userId = userId;
const responseData = await handler(req);
expect(responseData.event_type.bookingFields).toHaveLength(3);
const bookingFields = responseData.event_type.bookingFields as Array<{
name: string;
options?: Array<{ label: string; value: string }>;
maxLength?: number;
}>;
const selectField = bookingFields.find((f) => f.name === "customSelect");
expect(selectField?.options).toHaveLength(3);
expect(selectField?.options?.[0]).toMatchObject({
label: "Morning",
value: "morning",
});
const textareaField = bookingFields.find((f) => f.name === "customTextarea");
expect(textareaField?.maxLength).toBe(500);
});
test("Returns properly validated event type with team configuration", async () => {
const eventTypeId = 123456;
const teamId = 9999;
const userId = 333333;
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: eventTypeId,
},
});
const teamEventType = buildEventType({
id: eventTypeId,
teamId,
userId: null,
schedulingType: "COLLECTIVE",
assignAllTeamMembers: true,
});
prismaMock.eventType.findUnique.mockResolvedValue(teamEventType);
prismaMock.team.findFirst.mockResolvedValue({
id: teamId,
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
members: [
{
userId,
},
],
});
req.isSystemWideAdmin = false;
req.userId = userId;
const responseData = await handler(req);
expect(responseData.event_type.teamId).toBe(teamId);
expect(responseData.event_type.schedulingType).toBe("COLLECTIVE");
});
test("Returns properly validated event type with complex metadata", async () => {
const eventTypeId = 123456;
const userId = 333333;
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: eventTypeId,
},
});
const metadataEventType = buildEventType({
id: eventTypeId,
userId,
scheduleId: 1111,
metadata: {
additionalNotesRequired: true,
disableStandardEmails: false,
requiresConfirmationThreshold: {
time: 30,
unit: "minutes",
},
apps: {
stripe: {
price: 2500,
currency: "usd",
},
},
},
recurringEvent: {
freq: 2,
count: 12,
interval: 1,
},
bookingLimits: {
PER_DAY: 3,
PER_WEEK: 10,
},
durationLimits: {
PER_DAY: 480,
},
});
prismaMock.eventType.findUnique.mockResolvedValue(metadataEventType);
req.userId = userId;
const responseData = await handler(req);
expect(responseData.event_type.metadata).toBeDefined();
expect(responseData.event_type.recurringEvent).toMatchObject({
freq: 2,
count: 12,
interval: 1,
});
expect(responseData.event_type.bookingLimits).toMatchObject({
PER_DAY: 3,
PER_WEEK: 10,
});
expect(responseData.event_type.durationLimits).toMatchObject({
PER_DAY: 480,
});
});
});
});