* feat: auto-accept team invitations for existing users - Change email button text from 'View Invitation' to 'Accept Invite' - Implement auto-accept flow when clicking email CTA - Update TeamService.inviteMemberByToken to support auto-acceptance - Add new autoAcceptInvite tRPC endpoint for handling auto-acceptance - Update invitation link generation to include autoAccept parameter - Handle both team and organization invitation scenarios - Maintain payment/billing flow integration with TeamBilling.updateQuantity - Preserve backward compatibility with existing manual flow - Update all locale files with new 'Accept Invite' button text Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * revert: locale changes except English - Keep only English 'Accept Invite' translation - Revert all other locale files to original 'View Invitation' translations - Maintain core auto-accept invitation functionality Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * simplify: remove autoAccept parameter and make auto-acceptance default - Remove autoAccept parameter from TeamService.inviteMemberByToken - Always auto-accept invitations for existing users clicking email links - Remove autoAccept logic from teams server-page.tsx - Remove autoAccept=true from invitation URLs - Delete autoAcceptInvite handler and schema files - Remove autoAcceptInvite endpoint from tRPC router - Simplify invitation flow to match new user pattern Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * test: update teamService test to expect auto-accepted memberships - Change expectation from accepted: false to accepted: true - Update test description to reflect auto-accept behavior - Fix TypeScript type casting to use Pick<TeamRepository, 'deleteById'> - Aligns with new default auto-acceptance for team invitations Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * update * Update utils.ts * fix type error * delete token * add prisma transaction * update * update param * test: fix mock objects in teamService tests with realistic data - Fix duplicate property assignments in mock user objects - Use proper email format (user@example.com) for email fields - Use proper username format (testuser) for username fields - Fix logic error in acceptInvitationByToken (|| to &&) - Add autoAccept parameter to resendInvitation.handler.ts - All 16 tests passing with proper TypeScript types Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * test: add e2e tests for team invitation auto-accept flow - Add test for existing user auto-accepting team invitation via email link - Add test for error handling when wrong user tries to use invitation link - Verify proper user identity validation and database state changes - Follow existing e2e test patterns with browser context isolation - Fix ESLint warnings: replace conditional with assertion and remove unused browser parameter Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix * fix * fix: update team owner creation in e2e tests to include proper names - Fix email subject mismatch in auto-accept invitation tests - Team owners now created with explicit names instead of undefined - Matches pattern used in other working team invitation tests Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: update organization invitation test helper to match new auto-accept link format - Change expectExistingUserToBeInvitedToOrganization to look for 'teams?token' instead of 'settings/team' - Fixes 'Invite link not found' error in organization booking e2e test - Aligns with auto-accept invitation URL changes that use /teams?token= format - Fix eslint disable comment for playwright rule Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * address coderrabit review * fix failing test * addressed review --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
import { TeamBilling } from "@calcom/features/ee/billing/teams";
|
|
import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries";
|
|
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
|
|
import { deleteDomain } from "@calcom/lib/domainManager/organization";
|
|
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
|
import { WorkflowService } from "@calcom/lib/server/service/workflows";
|
|
import type { Membership, Team, User, VerificationToken, Profile } from "@calcom/prisma/client";
|
|
import { MembershipRole } from "@calcom/prisma/enums";
|
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
import { TeamService } from "./teamService";
|
|
|
|
vi.mock("@calcom/features/ee/billing/teams");
|
|
vi.mock("@calcom/lib/server/repository/team");
|
|
vi.mock("@calcom/lib/server/service/workflows");
|
|
vi.mock("@calcom/lib/domainManager/organization");
|
|
vi.mock("@calcom/features/ee/teams/lib/removeMember");
|
|
vi.mock("@calcom/lib/createAProfileForAnExistingUser");
|
|
vi.mock("@calcom/features/ee/teams/lib/queries");
|
|
|
|
const mockTeamBilling = {
|
|
cancel: vi.fn(),
|
|
updateQuantity: vi.fn(),
|
|
publish: vi.fn(),
|
|
downgrade: vi.fn(),
|
|
};
|
|
|
|
vi.mocked(TeamBilling.findAndInit).mockResolvedValue(mockTeamBilling);
|
|
|
|
describe("TeamService", () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(TeamBilling.findAndInit).mockResolvedValue(mockTeamBilling);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("delete", () => {
|
|
it("should delete team, cancel billing, and clean up", async () => {
|
|
const mockDeletedTeam = {
|
|
id: 1,
|
|
name: "Deleted Team",
|
|
isOrganization: true,
|
|
slug: "deleted-team",
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
const mockTeamRepo = {
|
|
deleteById: vi.fn().mockResolvedValue(mockDeletedTeam),
|
|
} as Pick<TeamRepository, "deleteById">;
|
|
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo);
|
|
|
|
const result = await TeamService.delete({ id: 1 });
|
|
|
|
expect(TeamBilling.findAndInit).toHaveBeenCalledWith(1);
|
|
expect(mockTeamBilling.cancel).toHaveBeenCalled();
|
|
expect(WorkflowService.deleteWorkflowRemindersOfRemovedTeam).toHaveBeenCalledWith(1);
|
|
expect(mockTeamRepo.deleteById).toHaveBeenCalledWith({ id: 1 });
|
|
expect(deleteDomain).toHaveBeenCalledWith("deleted-team");
|
|
expect(result).toEqual(mockDeletedTeam);
|
|
});
|
|
});
|
|
|
|
describe("inviteMemberByToken", () => {
|
|
it("should throw error if verification token is not found", async () => {
|
|
prismaMock.verificationToken.findFirst.mockResolvedValue(null);
|
|
await expect(TeamService.inviteMemberByToken("invalid-token", 1)).rejects.toThrow(TRPCError);
|
|
});
|
|
|
|
it("should create provisional membership and update billing", async () => {
|
|
const mockToken = {
|
|
teamId: 1,
|
|
team: { name: "Test Team" },
|
|
expiresInDays: null,
|
|
expires: new Date(Date.now() + 86400000),
|
|
token: "valid-token",
|
|
identifier: "test@example.com",
|
|
id: "1",
|
|
};
|
|
prismaMock.verificationToken.findFirst.mockResolvedValue(mockToken);
|
|
prismaMock.membership.create.mockResolvedValue({} as Membership);
|
|
|
|
const result = await TeamService.inviteMemberByToken("valid-token", 1);
|
|
|
|
expect(prismaMock.membership.create).toHaveBeenCalledWith({
|
|
data: {
|
|
accepted: false,
|
|
createdAt: expect.any(Date),
|
|
role: MembershipRole.MEMBER,
|
|
teamId: 1,
|
|
userId: 1,
|
|
},
|
|
});
|
|
expect(mockTeamBilling.updateQuantity).toHaveBeenCalled();
|
|
expect(result).toBe("Test Team");
|
|
});
|
|
});
|
|
|
|
describe("acceptTeamMembership", () => {
|
|
it("should accept membership and update event types for regular team", async () => {
|
|
const mockMembership = {
|
|
team: { id: 1, parentId: null, isOrganization: false },
|
|
};
|
|
|
|
prismaMock.membership.update.mockResolvedValue(mockMembership as Membership & { team: Team });
|
|
vi.mocked(updateNewTeamMemberEventTypes).mockResolvedValue(undefined);
|
|
|
|
await TeamService.acceptTeamMembership({
|
|
userId: 1,
|
|
teamId: 1,
|
|
userEmail: "test@example.com",
|
|
username: "testuser",
|
|
});
|
|
|
|
expect(prismaMock.membership.update).toHaveBeenCalledWith({
|
|
where: { userId_teamId: { userId: 1, teamId: 1 } },
|
|
data: { accepted: true },
|
|
select: { team: true },
|
|
});
|
|
expect(updateNewTeamMemberEventTypes).toHaveBeenCalledWith(1, 1);
|
|
});
|
|
|
|
it("should accept membership and create profile for organization", async () => {
|
|
const mockMembership = {
|
|
team: { id: 1, parentId: null, isOrganization: true },
|
|
};
|
|
|
|
prismaMock.membership.update.mockResolvedValue(mockMembership as Membership & { team: Team });
|
|
vi.mocked(createAProfileForAnExistingUser).mockResolvedValue({} as Profile);
|
|
vi.mocked(updateNewTeamMemberEventTypes).mockResolvedValue(undefined);
|
|
|
|
await TeamService.acceptTeamMembership({
|
|
userId: 1,
|
|
teamId: 1,
|
|
userEmail: "test@example.com",
|
|
username: "testuser",
|
|
});
|
|
|
|
expect(createAProfileForAnExistingUser).toHaveBeenCalledWith({
|
|
user: {
|
|
id: 1,
|
|
email: "test@example.com",
|
|
currentUsername: "testuser",
|
|
},
|
|
organizationId: 1,
|
|
});
|
|
});
|
|
|
|
it("should accept membership and handle parent team for subteam", async () => {
|
|
const mockMembership = {
|
|
team: { id: 1, parentId: 2, isOrganization: false },
|
|
};
|
|
|
|
prismaMock.membership.update
|
|
.mockResolvedValueOnce(mockMembership as Membership & { team: Team })
|
|
.mockResolvedValueOnce({} as Membership);
|
|
vi.mocked(createAProfileForAnExistingUser).mockResolvedValue({} as Profile);
|
|
vi.mocked(updateNewTeamMemberEventTypes).mockResolvedValue(undefined);
|
|
|
|
await TeamService.acceptTeamMembership({
|
|
userId: 1,
|
|
teamId: 1,
|
|
userEmail: "test@example.com",
|
|
username: "testuser",
|
|
});
|
|
|
|
expect(prismaMock.membership.update).toHaveBeenCalledTimes(2);
|
|
expect(prismaMock.membership.update).toHaveBeenNthCalledWith(2, {
|
|
where: { userId_teamId: { userId: 1, teamId: 2 } },
|
|
data: { accepted: true },
|
|
});
|
|
expect(createAProfileForAnExistingUser).toHaveBeenCalledWith({
|
|
user: {
|
|
id: 1,
|
|
email: "test@example.com",
|
|
currentUsername: "testuser",
|
|
},
|
|
organizationId: 2,
|
|
});
|
|
});
|
|
});
|
|
describe("leaveTeamMembership", () => {
|
|
it("should delete membership when rejecting invitation", async () => {
|
|
const mockMembership = {
|
|
team: { id: 1, parentId: null },
|
|
};
|
|
|
|
prismaMock.membership.delete.mockResolvedValue(mockMembership as Membership & { team: Team });
|
|
|
|
await TeamService.leaveTeamMembership({
|
|
userId: 1,
|
|
teamId: 1,
|
|
});
|
|
|
|
expect(prismaMock.membership.delete).toHaveBeenCalledWith({
|
|
where: { userId_teamId: { userId: 1, teamId: 1 } },
|
|
select: { team: true },
|
|
});
|
|
});
|
|
|
|
it("should delete parent membership when rejecting subteam invitation", async () => {
|
|
const mockMembership = {
|
|
team: { id: 1, parentId: 2 },
|
|
};
|
|
|
|
prismaMock.membership.delete
|
|
.mockResolvedValueOnce(mockMembership as Membership & { team: Team })
|
|
.mockResolvedValueOnce({} as Membership);
|
|
|
|
await TeamService.leaveTeamMembership({
|
|
userId: 1,
|
|
teamId: 1,
|
|
});
|
|
|
|
expect(prismaMock.membership.delete).toHaveBeenCalledTimes(2);
|
|
expect(prismaMock.membership.delete).toHaveBeenNthCalledWith(2, {
|
|
where: { userId_teamId: { userId: 1, teamId: 2 } },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("acceptInvitationByToken", () => {
|
|
it("should throw error if verification token is not found", async () => {
|
|
prismaMock.verificationToken.findFirst.mockResolvedValue(null);
|
|
await expect(TeamService.acceptInvitationByToken("invalid-token", 1)).rejects.toThrow(TRPCError);
|
|
});
|
|
|
|
it("should throw error if token is not associated with team", async () => {
|
|
const mockToken = {
|
|
teamId: null,
|
|
team: null,
|
|
identifier: "test@example.com",
|
|
id: "1",
|
|
};
|
|
|
|
prismaMock.verificationToken.findFirst.mockResolvedValue(
|
|
mockToken as VerificationToken & { team: Team | null }
|
|
);
|
|
|
|
await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow(
|
|
new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Invite token is not associated with any team",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should throw error if user not found", async () => {
|
|
const mockToken = {
|
|
teamId: 1,
|
|
team: { name: "Test Team" },
|
|
identifier: "test@example.com",
|
|
id: "1",
|
|
};
|
|
|
|
prismaMock.verificationToken.findFirst.mockResolvedValue(
|
|
mockToken as VerificationToken & { team: Team }
|
|
);
|
|
prismaMock.user.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow(
|
|
new TRPCError({ code: "NOT_FOUND", message: "User not found" })
|
|
);
|
|
});
|
|
|
|
it("should throw error if user email doesn't match token identifier", async () => {
|
|
const mockToken = {
|
|
teamId: 1,
|
|
team: { name: "Test Team" },
|
|
identifier: "invited@example.com",
|
|
id: "1",
|
|
};
|
|
|
|
const mockUser = {
|
|
email: "different@example.com",
|
|
username: "testuser",
|
|
};
|
|
|
|
prismaMock.verificationToken.findFirst.mockResolvedValue(
|
|
mockToken as VerificationToken & { team: Team }
|
|
);
|
|
prismaMock.user.findUnique.mockResolvedValue(mockUser as User);
|
|
|
|
await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow(
|
|
new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "This invitation is not for your account",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should throw error if user username doesn't match token identifier", async () => {
|
|
const mockToken = {
|
|
teamId: 1,
|
|
team: { name: "Test Team" },
|
|
identifier: "inviteduser",
|
|
id: "1",
|
|
};
|
|
|
|
const mockUser = {
|
|
email: "test@example.com",
|
|
username: "differentuser",
|
|
};
|
|
|
|
prismaMock.verificationToken.findFirst.mockResolvedValue(
|
|
mockToken as VerificationToken & { team: Team }
|
|
);
|
|
prismaMock.user.findUnique.mockResolvedValue(mockUser as User);
|
|
|
|
await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow(
|
|
new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "This invitation is not for your account",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should accept invitation when user email matches token identifier", async () => {
|
|
const mockToken = {
|
|
teamId: 1,
|
|
team: { name: "Test Team" },
|
|
identifier: "test@example.com",
|
|
id: "1",
|
|
};
|
|
|
|
const mockUser = {
|
|
email: "test@example.com",
|
|
username: "testuser",
|
|
};
|
|
|
|
const mockMembership = {
|
|
team: { id: 1, parentId: null, isOrganization: false },
|
|
};
|
|
|
|
prismaMock.verificationToken.findFirst.mockResolvedValue(
|
|
mockToken as VerificationToken & { team: Team }
|
|
);
|
|
prismaMock.user.findUnique.mockResolvedValue(mockUser as User);
|
|
prismaMock.membership.update.mockResolvedValue(mockMembership as Membership & { team: Team });
|
|
vi.mocked(updateNewTeamMemberEventTypes).mockResolvedValue(undefined);
|
|
|
|
await TeamService.acceptInvitationByToken("valid-token", 1);
|
|
|
|
expect(prismaMock.membership.update).toHaveBeenCalledWith({
|
|
where: { userId_teamId: { userId: 1, teamId: 1 } },
|
|
data: { accepted: true },
|
|
select: { team: true },
|
|
});
|
|
});
|
|
|
|
it("should accept invitation when user username matches token identifier", async () => {
|
|
const mockToken = {
|
|
teamId: 1,
|
|
team: { name: "Test Team" },
|
|
identifier: "testuser",
|
|
id: "1",
|
|
};
|
|
|
|
const mockUser = {
|
|
email: "testuser@example.com",
|
|
username: "testuser",
|
|
};
|
|
|
|
const mockMembership = {
|
|
team: { id: 1, parentId: null, isOrganization: false },
|
|
};
|
|
|
|
prismaMock.verificationToken.findFirst.mockResolvedValue(
|
|
mockToken as VerificationToken & { team: Team }
|
|
);
|
|
prismaMock.user.findUnique.mockResolvedValue(mockUser as User);
|
|
prismaMock.membership.update.mockResolvedValue(mockMembership as Membership & { team: Team });
|
|
vi.mocked(updateNewTeamMemberEventTypes).mockResolvedValue(undefined);
|
|
|
|
await TeamService.acceptInvitationByToken("valid-token", 1);
|
|
|
|
expect(prismaMock.membership.update).toHaveBeenCalledWith({
|
|
where: { userId_teamId: { userId: 1, teamId: 1 } },
|
|
data: { accepted: true },
|
|
select: { team: true },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("publish", () => {
|
|
it("should call publish on TeamBilling", async () => {
|
|
await TeamService.publish(1);
|
|
|
|
expect(TeamBilling.findAndInit).toHaveBeenCalledWith(1);
|
|
expect(mockTeamBilling.publish).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|