test(user-workspace): add unit tests for UserWorkspaceService + review #11239 (#11256)

This commit is contained in:
Antoine Moreaux
2025-03-30 08:37:16 +02:00
committed by GitHub
parent ce07d2645c
commit eea30828a4
4 changed files with 700 additions and 11 deletions

View File

@ -170,9 +170,7 @@ export class AuthResolver {
(await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
)) ??
(await this.userWorkspaceService.findFirstRandomWorkspaceByUserId(
user.id,
));
(await this.userWorkspaceService.findFirstWorkspaceByUserId(user.id));
await this.userService.markEmailAsVerified(user.id);

View File

@ -47,9 +47,7 @@ export class DomainManagerService {
searchParams: Record<string, string | number>,
) {
Object.entries(searchParams).forEach(([key, value]) => {
if (isDefined(value)) {
url.searchParams.set(key, value.toString());
}
url.searchParams.set(key, value.toString());
});
}

View File

@ -0,0 +1,680 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
describe('UserWorkspaceService', () => {
let service: UserWorkspaceService;
let userWorkspaceRepository: Repository<UserWorkspace>;
let userRepository: Repository<User>;
let objectMetadataRepository: Repository<ObjectMetadataEntity>;
let dataSourceService: DataSourceService;
let typeORMService: TypeORMService;
let workspaceInvitationService: WorkspaceInvitationService;
let workspaceEventEmitter: WorkspaceEventEmitter;
let domainManagerService: DomainManagerService;
let twentyORMGlobalManager: TwentyORMGlobalManager;
let userRoleService: UserRoleService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserWorkspaceService,
{
provide: getRepositoryToken(UserWorkspace, 'core'),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOneBy: jest.fn(),
countBy: jest.fn(),
exists: jest.fn(),
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(User, 'core'),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(ObjectMetadataEntity, 'metadata'),
useValue: {
findOneOrFail: jest.fn(),
},
},
{
provide: DataSourceService,
useValue: {
getLastDataSourceMetadataFromWorkspaceIdOrFail: jest.fn(),
},
},
{
provide: TypeORMService,
useValue: {
connectToDataSource: jest.fn(),
},
},
{
provide: WorkspaceInvitationService,
useValue: {
invalidateWorkspaceInvitation: jest.fn(),
},
},
{
provide: WorkspaceEventEmitter,
useValue: {
emitCustomBatchEvent: jest.fn(),
emitDatabaseBatchEvent: jest.fn(),
},
},
{
provide: DomainManagerService,
useValue: {
getWorkspaceUrls: jest.fn(),
},
},
{
provide: TwentyORMGlobalManager,
useValue: {
getRepositoryForWorkspace: jest.fn(),
},
},
{
provide: UserRoleService,
useValue: {
assignRoleToUserWorkspace: jest.fn(),
},
},
],
}).compile();
service = module.get<UserWorkspaceService>(UserWorkspaceService);
userWorkspaceRepository = module.get(
getRepositoryToken(UserWorkspace, 'core'),
);
userRepository = module.get(getRepositoryToken(User, 'core'));
objectMetadataRepository = module.get(
getRepositoryToken(ObjectMetadataEntity, 'metadata'),
);
dataSourceService = module.get<DataSourceService>(DataSourceService);
typeORMService = module.get<TypeORMService>(TypeORMService);
workspaceInvitationService = module.get<WorkspaceInvitationService>(
WorkspaceInvitationService,
);
workspaceEventEmitter = module.get<WorkspaceEventEmitter>(
WorkspaceEventEmitter,
);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager,
);
userRoleService = module.get<UserRoleService>(UserRoleService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should create a user workspace and emit an event', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = { userId, workspaceId } as UserWorkspace;
jest
.spyOn(userWorkspaceRepository, 'create')
.mockReturnValue(userWorkspace);
jest
.spyOn(userWorkspaceRepository, 'save')
.mockResolvedValue(userWorkspace);
jest
.spyOn(workspaceEventEmitter, 'emitCustomBatchEvent')
.mockImplementation();
const result = await service.create(userId, workspaceId);
expect(userWorkspaceRepository.create).toHaveBeenCalledWith({
userId,
workspaceId,
});
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
USER_SIGNUP_EVENT_NAME,
[{ userId }],
workspaceId,
);
expect(result).toEqual(userWorkspace);
});
});
describe('createWorkspaceMember', () => {
it('should create a workspace member', async () => {
const workspaceId = 'workspace-id';
const user = {
id: 'user-id',
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
defaultAvatarUrl: 'avatar-url',
locale: 'en',
} as User;
const dataSourceMetadata = {
schema: 'public',
} as DataSourceEntity;
const workspaceDataSource = {
query: jest.fn(),
};
const workspaceMember = [
{
id: 'workspace-member-id',
nameFirstName: 'John',
nameLastName: 'Doe',
userId: 'user-id',
userEmail: 'test@example.com',
},
];
const objectMetadata = {
nameSingular: 'workspaceMember',
} as ObjectMetadataEntity;
jest
.spyOn(
dataSourceService,
'getLastDataSourceMetadataFromWorkspaceIdOrFail',
)
.mockResolvedValue(dataSourceMetadata);
jest
.spyOn(typeORMService, 'connectToDataSource')
.mockResolvedValue(workspaceDataSource as any);
workspaceDataSource.query
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(workspaceMember);
jest
.spyOn(objectMetadataRepository, 'findOneOrFail')
.mockResolvedValue(objectMetadata);
jest
.spyOn(workspaceEventEmitter, 'emitDatabaseBatchEvent')
.mockImplementation();
await service.createWorkspaceMember(workspaceId, user);
expect(
dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail,
).toHaveBeenCalledWith(workspaceId);
expect(typeORMService.connectToDataSource).toHaveBeenCalledWith(
dataSourceMetadata,
);
expect(workspaceDataSource.query).toHaveBeenCalledTimes(2);
expect(objectMetadataRepository.findOneOrFail).toHaveBeenCalledWith({
where: {
nameSingular: 'workspaceMember',
workspaceId,
},
});
expect(workspaceEventEmitter.emitDatabaseBatchEvent).toHaveBeenCalledWith(
{
objectMetadataNameSingular: 'workspaceMember',
action: DatabaseEventAction.CREATED,
events: [
{
recordId: workspaceMember[0].id,
objectMetadata,
properties: {
after: workspaceMember[0],
},
},
],
workspaceId,
},
);
});
});
describe('addUserToWorkspaceIfUserNotInWorkspace', () => {
it('should add user to workspace if not already in workspace', async () => {
const user = {
id: 'user-id',
email: 'test@example.com',
} as User;
const workspace = {
id: 'workspace-id',
defaultRoleId: 'default-role-id',
} as Workspace;
const userWorkspace = {
id: 'user-workspace-id',
userId: user.id,
workspaceId: workspace.id,
} as UserWorkspace;
jest.spyOn(service, 'checkUserWorkspaceExists').mockResolvedValue(null);
jest.spyOn(service, 'create').mockResolvedValue(userWorkspace);
jest.spyOn(service, 'createWorkspaceMember').mockResolvedValue(undefined);
jest
.spyOn(userRoleService, 'assignRoleToUserWorkspace')
.mockResolvedValue(undefined);
jest
.spyOn(workspaceInvitationService, 'invalidateWorkspaceInvitation')
.mockResolvedValue(undefined);
await service.addUserToWorkspaceIfUserNotInWorkspace(user, workspace);
expect(service.checkUserWorkspaceExists).toHaveBeenCalledWith(
user.id,
workspace.id,
);
expect(service.create).toHaveBeenCalledWith(user.id, workspace.id);
expect(service.createWorkspaceMember).toHaveBeenCalledWith(
workspace.id,
user,
);
expect(userRoleService.assignRoleToUserWorkspace).toHaveBeenCalledWith({
workspaceId: workspace.id,
userWorkspaceId: userWorkspace.id,
roleId: workspace.defaultRoleId,
});
expect(
workspaceInvitationService.invalidateWorkspaceInvitation,
).toHaveBeenCalledWith(workspace.id, user.email);
});
it('should not add user to workspace if already in workspace', async () => {
const user = {
id: 'user-id',
email: 'test@example.com',
} as User;
const workspace = {
id: 'workspace-id',
defaultRoleId: 'default-role-id',
} as Workspace;
const userWorkspace = {
id: 'user-workspace-id',
userId: user.id,
workspaceId: workspace.id,
} as UserWorkspace;
jest
.spyOn(service, 'checkUserWorkspaceExists')
.mockResolvedValue(userWorkspace);
jest.spyOn(service, 'create').mockResolvedValue(userWorkspace);
jest.spyOn(service, 'createWorkspaceMember').mockResolvedValue(undefined);
await service.addUserToWorkspaceIfUserNotInWorkspace(user, workspace);
expect(service.checkUserWorkspaceExists).toHaveBeenCalledWith(
user.id,
workspace.id,
);
expect(service.create).not.toHaveBeenCalled();
expect(service.createWorkspaceMember).not.toHaveBeenCalled();
});
it('should throw an exception if workspace has no default role', async () => {
const user = {
id: 'user-id',
email: 'test@example.com',
} as User;
const workspace = {
id: 'workspace-id',
defaultRoleId: undefined,
} as unknown as Workspace;
jest.spyOn(service, 'checkUserWorkspaceExists').mockResolvedValue(null);
jest.spyOn(service, 'create').mockResolvedValue({} as UserWorkspace);
jest.spyOn(service, 'createWorkspaceMember').mockResolvedValue(undefined);
await expect(
service.addUserToWorkspaceIfUserNotInWorkspace(user, workspace),
).rejects.toThrow(PermissionsException);
});
});
describe('getUserCount', () => {
it('should return the count of users in a workspace', async () => {
const workspaceId = 'workspace-id';
const count = 5;
jest.spyOn(userWorkspaceRepository, 'countBy').mockResolvedValue(count);
const result = await service.getUserCount(workspaceId);
expect(userWorkspaceRepository.countBy).toHaveBeenCalledWith({
workspaceId,
});
expect(result).toEqual(count);
});
});
describe('checkUserWorkspaceExists', () => {
it('should check if a user workspace exists', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = { userId, workspaceId } as UserWorkspace;
jest
.spyOn(userWorkspaceRepository, 'findOneBy')
.mockResolvedValue(userWorkspace);
const result = await service.checkUserWorkspaceExists(
userId,
workspaceId,
);
expect(userWorkspaceRepository.findOneBy).toHaveBeenCalledWith({
userId,
workspaceId,
});
expect(result).toEqual(userWorkspace);
});
it('should return null if user workspace does not exist', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
jest.spyOn(userWorkspaceRepository, 'findOneBy').mockResolvedValue(null);
const result = await service.checkUserWorkspaceExists(
userId,
workspaceId,
);
expect(userWorkspaceRepository.findOneBy).toHaveBeenCalledWith({
userId,
workspaceId,
});
expect(result).toBeNull();
});
});
describe('checkUserWorkspaceExistsByEmail', () => {
it('should check if a user workspace exists by email', async () => {
const email = 'test@example.com';
const workspaceId = 'workspace-id';
jest.spyOn(userWorkspaceRepository, 'exists').mockResolvedValue(true);
const result = await service.checkUserWorkspaceExistsByEmail(
email,
workspaceId,
);
expect(userWorkspaceRepository.exists).toHaveBeenCalledWith({
where: {
workspaceId,
user: {
email,
},
},
relations: {
user: true,
},
});
expect(result).toBe(true);
});
});
describe('findAvailableWorkspacesByEmail', () => {
it('should find available workspaces for an email', async () => {
const email = 'test@example.com';
const workspace1 = {
id: 'workspace-id-1',
displayName: 'Workspace 1',
logo: 'logo1.png',
workspaceSSOIdentityProviders: [
{
id: 'sso-id-1',
name: 'SSO Provider 1',
issuer: 'issuer1',
type: 'type1',
status: 'Active',
},
{
id: 'sso-id-2',
name: 'SSO Provider 2',
issuer: 'issuer2',
type: 'type2',
status: 'Inactive',
},
],
} as unknown as Workspace;
const workspace2 = {
id: 'workspace-id-2',
displayName: 'Workspace 2',
logo: 'logo2.png',
workspaceSSOIdentityProviders: [],
} as unknown as Workspace;
const user = {
email,
workspaces: [
{
workspaceId: workspace1.id,
workspace: workspace1,
},
{
workspaceId: workspace2.id,
workspace: workspace2,
},
],
} as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
jest
.spyOn(domainManagerService, 'getWorkspaceUrls')
.mockReturnValueOnce({
customUrl: 'https://crm.custom1.com',
subdomainUrl: 'https://workspace1.twenty.com',
})
.mockReturnValueOnce({
customUrl: 'https://crm.custom2.com',
subdomainUrl: 'https://workspace2.twenty.com',
});
const result = await service.findAvailableWorkspacesByEmail(email);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: {
email,
},
relations: [
'workspaces',
'workspaces.workspace',
'workspaces.workspace.workspaceSSOIdentityProviders',
],
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
id: workspace1.id,
displayName: workspace1.displayName,
workspaceUrls: {
customUrl: 'https://crm.custom1.com',
subdomainUrl: 'https://workspace1.twenty.com',
},
logo: workspace1.logo,
sso: [
{
id: 'sso-id-1',
name: 'SSO Provider 1',
issuer: 'issuer1',
type: 'type1',
status: 'Active',
},
],
});
expect(result[1]).toEqual({
id: workspace2.id,
displayName: workspace2.displayName,
workspaceUrls: {
customUrl: 'https://crm.custom2.com',
subdomainUrl: 'https://workspace2.twenty.com',
},
logo: workspace2.logo,
sso: [],
});
});
it('should throw an exception if user is not found', async () => {
const email = 'nonexistent@example.com';
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(
service.findAvailableWorkspacesByEmail(email),
).rejects.toThrow(AuthException);
});
});
describe('findFirstWorkspaceByUserId', () => {
it('should find the first workspace for a user', async () => {
const userId = 'user-id';
const workspace1 = {
id: 'workspace-id',
createdAt: '2025-01-02T00:00:00.000Z',
} as unknown as Workspace;
const workspace2 = {
id: 'workspace-id-2',
createdAt: '2025-01-01T00:00:00.000Z',
} as unknown as Workspace;
const user = {
id: userId,
workspaces: [{ workspace: workspace1 }, { workspace: workspace2 }],
} as unknown as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
const result = await service.findFirstWorkspaceByUserId(userId);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: {
id: userId,
},
relations: ['workspaces', 'workspaces.workspace'],
order: {
workspaces: {
workspace: {
createdAt: 'ASC',
},
},
},
});
expect(result).toEqual(workspace1);
});
it('should throw an exception if no workspace is found', async () => {
const userId = 'user-id';
const user = {
id: userId,
workspaces: [],
} as unknown as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
await expect(service.findFirstWorkspaceByUserId(userId)).rejects.toThrow(
AuthException,
);
});
});
describe('getUserWorkspaceForUserOrThrow', () => {
it('should get a user workspace or throw', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = { userId, workspaceId } as UserWorkspace;
jest
.spyOn(userWorkspaceRepository, 'findOne')
.mockResolvedValue(userWorkspace);
const result = await service.getUserWorkspaceForUserOrThrow({
userId,
workspaceId,
});
expect(userWorkspaceRepository.findOne).toHaveBeenCalledWith({
where: {
userId,
workspaceId,
},
});
expect(result).toEqual(userWorkspace);
});
it('should throw an exception if user workspace is not found', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
jest.spyOn(userWorkspaceRepository, 'findOne').mockResolvedValue(null);
await expect(
service.getUserWorkspaceForUserOrThrow({ userId, workspaceId }),
).rejects.toThrow('User workspace not found');
});
});
describe('getWorkspaceMemberOrThrow', () => {
it('should get a workspace member or throw', async () => {
const workspaceMemberId = 'workspace-member-id';
const workspaceId = 'workspace-id';
const workspaceMember = {
id: workspaceMemberId,
} as WorkspaceMemberWorkspaceEntity;
const workspaceMemberRepository = {
findOne: jest.fn().mockResolvedValue(workspaceMember),
};
jest
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue(workspaceMemberRepository as any);
const result = await service.getWorkspaceMemberOrThrow({
workspaceMemberId,
workspaceId,
});
expect(
twentyORMGlobalManager.getRepositoryForWorkspace,
).toHaveBeenCalledWith(workspaceId, 'workspaceMember');
expect(workspaceMemberRepository.findOne).toHaveBeenCalledWith({
where: {
id: workspaceMemberId,
},
});
expect(result).toEqual(workspaceMember);
});
it('should throw an exception if workspace member is not found', async () => {
const workspaceMemberId = 'workspace-member-id';
const workspaceId = 'workspace-id';
const workspaceMemberRepository = {
findOne: jest.fn().mockResolvedValue(null),
};
jest
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue(workspaceMemberRepository as any);
await expect(
service.getWorkspaceMemberOrThrow({ workspaceMemberId, workspaceId }),
).rejects.toThrow('Workspace member not found');
});
});
});

View File

@ -32,6 +32,7 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { assert } from 'src/utils/assert';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
constructor(
@ -186,20 +187,32 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
});
}
async findFirstRandomWorkspaceByUserId(userId: string) {
async findFirstWorkspaceByUserId(userId: string) {
const user = await this.userRepository.findOne({
where: {
id: userId,
},
relations: ['workspaces', 'workspaces.workspace'],
order: {
workspaces: {
workspace: {
createdAt: 'ASC',
},
},
},
});
userValidator.assertIsDefinedOrThrow(
user,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
const workspace = user?.workspaces?.[0]?.workspace;
workspaceValidator.assertIsDefinedOrThrow(
workspace,
new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
),
);
return user.workspaces[0].workspace;
return workspace;
}
async findAvailableWorkspacesByEmail(email: string) {