From 6fb42b63e0d6be53eaba8839856f576d6464842a Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 12 Jan 2026 12:01:43 +0000 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * fix: update tests to use handler return value instead of res._getData() Co-Authored-By: keith@cal.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 --- .../event-types/[id]/_get.integration-test.ts | 545 ++++++++++++++++++ .../v1/test/lib/event-types/[id]/_get.test.ts | 349 +++++++++++ 2 files changed, 894 insertions(+) create mode 100644 apps/api/v1/test/lib/event-types/[id]/_get.integration-test.ts diff --git a/apps/api/v1/test/lib/event-types/[id]/_get.integration-test.ts b/apps/api/v1/test/lib/event-types/[id]/_get.integration-test.ts new file mode 100644 index 0000000000..ecdc1d0ead --- /dev/null +++ b/apps/api/v1/test/lib/event-types/[id]/_get.integration-test.ts @@ -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({ + 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({ + 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({ + 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({ + 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({ + 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>; + expect(locations[0]).toMatchObject({ + type: "integrations:zoom", + link: "https://zoom.us/j/123456789", + }); + + const bookingFields = responseData.event_type.bookingFields as Array>; + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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, + }); + }); + }); +}); diff --git a/apps/api/v1/test/lib/event-types/[id]/_get.test.ts b/apps/api/v1/test/lib/event-types/[id]/_get.test.ts index 2cf49027f3..44cb8aa3cd 100644 --- a/apps/api/v1/test/lib/event-types/[id]/_get.test.ts +++ b/apps/api/v1/test/lib/event-types/[id]/_get.test.ts @@ -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({ + 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>; + expect(locations[0]).toMatchObject({ + type: "integrations:zoom", + link: "https://zoom.us/j/123456789", + }); + + const bookingFields = responseData.event_type.bookingFields as Array>; + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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, + }); + }); + }); });