diff --git a/server/src/guards/__test/survey.guard.spec.ts b/server/src/guards/__test/survey.guard.spec.ts index 4c30c453..ccc1e7fd 100644 --- a/server/src/guards/__test/survey.guard.spec.ts +++ b/server/src/guards/__test/survey.guard.spec.ts @@ -11,6 +11,7 @@ import { NoPermissionException } from 'src/exceptions/noPermissionException'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { WorkspaceMember } from 'src/models/workspaceMember.entity'; import { Collaborator } from 'src/models/collaborator.entity'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; describe('SurveyGuard', () => { let guard: SurveyGuard; @@ -81,7 +82,19 @@ describe('SurveyGuard', () => { ); }); - it('should allow access if user is the owner of the survey', async () => { + it('should allow access if user is the owner of the survey by ownerId', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { ownerId: 'testUserId', workspaceId: null }; + jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + + it('should allow access if user is the owner of the survey by username', async () => { const context = createMockExecutionContext(); const surveyMeta = { owner: 'testUser', workspaceId: null }; jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); @@ -108,7 +121,35 @@ describe('SurveyGuard', () => { expect(result).toBe(true); }); - it('should throw NoPermissionException if user has no permissions', async () => { + it('should throw NoPermissionException if user is not a workspace member', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { owner: 'anotherUser', workspaceId: 'workspaceId' }; + jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest.spyOn(workspaceMemberService, 'findOne').mockResolvedValue(null); + + await expect(guard.canActivate(context)).rejects.toThrow( + NoPermissionException, + ); + }); + + it('should throw NoPermissionException if no permissions are provided', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { owner: 'anotherUser', workspaceId: null }; + jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId'); + jest.spyOn(reflector, 'get').mockReturnValueOnce(null); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + + await expect(guard.canActivate(context)).rejects.toThrow( + NoPermissionException, + ); + }); + + it('should throw NoPermissionException if user has no matching permissions', async () => { const context = createMockExecutionContext(); const surveyMeta = { owner: 'anotherUser', workspaceId: null }; jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId'); @@ -125,6 +166,24 @@ describe('SurveyGuard', () => { ); }); + it('should allow access if user has the required permissions', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { owner: 'anotherUser', workspaceId: null }; + jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId'); + jest + .spyOn(reflector, 'get') + .mockReturnValueOnce([SURVEY_PERMISSION.SURVEY_CONF_MANAGE]); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest.spyOn(collaboratorService, 'getCollaborator').mockResolvedValue({ + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + } as Collaborator); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + function createMockExecutionContext(): ExecutionContext { return { switchToHttp: jest.fn().mockReturnValue({ diff --git a/server/src/modules/auth/__test/user.service.spec.ts b/server/src/modules/auth/__test/user.service.spec.ts index a3bcc058..e0cdfc19 100644 --- a/server/src/modules/auth/__test/user.service.spec.ts +++ b/server/src/modules/auth/__test/user.service.spec.ts @@ -6,6 +6,7 @@ import { User } from 'src/models/user.entity'; import { HttpException } from 'src/exceptions/httpException'; import { hash256 } from 'src/utils/hash256'; import { RECORD_STATUS } from 'src/enums'; +import { ObjectId } from 'mongodb'; describe('UserService', () => { let service: UserService; @@ -21,6 +22,7 @@ describe('UserService', () => { create: jest.fn(), save: jest.fn(), findOne: jest.fn(), + find: jest.fn(), }, }, ], @@ -32,6 +34,10 @@ describe('UserService', () => { ); }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); + it('should create a user', async () => { const userInfo = { username: 'testUser', @@ -102,7 +108,7 @@ describe('UserService', () => { expect(user).toEqual({ ...userInfo, password: hashedPassword }); }); - it('should return undefined when user is not found by credentials', async () => { + it('should return null when user is not found by credentials', async () => { const userInfo = { username: 'nonExistingUser', password: 'nonExistingPassword', @@ -129,7 +135,8 @@ describe('UserService', () => { const userInfo = { username: username, password: 'existingPassword', - } as User; + curStatus: { status: 'ACTIVE' }, + } as unknown as User; jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo); @@ -137,10 +144,129 @@ describe('UserService', () => { expect(userRepository.findOne).toHaveBeenCalledWith({ where: { - 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, username: username, + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, }, }); expect(user).toEqual(userInfo); }); + + it('should return null when user is not found by username', async () => { + const username = 'nonExistingUser'; + + const findOneSpy = jest + .spyOn(userRepository, 'findOne') + .mockResolvedValue(null); + + const user = await service.getUserByUsername(username); + + expect(findOneSpy).toHaveBeenCalledWith({ + where: { + username: username, + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + }); + expect(user).toBe(null); + }); + + it('should return a user by id', async () => { + const id = '60c72b2f9b1e8a5f4b123456'; + const userInfo = { + _id: new ObjectId(id), + username: 'testUser', + curStatus: { status: 'ACTIVE' }, + } as unknown as User; + + jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo); + + const user = await service.getUserById(id); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { + _id: new ObjectId(id), + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + }); + expect(user).toEqual(userInfo); + }); + + it('should return null when user is not found by id', async () => { + const id = '60c72b2f9b1e8a5f4b123456'; + + const findOneSpy = jest + .spyOn(userRepository, 'findOne') + .mockResolvedValue(null); + + const user = await service.getUserById(id); + + expect(findOneSpy).toHaveBeenCalledWith({ + where: { + _id: new ObjectId(id), + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + }); + expect(user).toBe(null); + }); + + it('should return a list of users by username', async () => { + const username = 'test'; + const userList = [ + { _id: new ObjectId(), username: 'testUser1', createDate: new Date() }, + { _id: new ObjectId(), username: 'testUser2', createDate: new Date() }, + ]; + + jest + .spyOn(userRepository, 'find') + .mockResolvedValue(userList as unknown as User[]); + + const result = await service.getUserListByUsername({ + username, + skip: 0, + take: 10, + }); + + expect(userRepository.find).toHaveBeenCalledWith({ + where: { + username: new RegExp(username), + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + skip: 0, + take: 10, + select: ['_id', 'username', 'createDate'], + }); + expect(result).toEqual(userList); + }); + + it('should return a list of users by ids', async () => { + const idList = ['60c72b2f9b1e8a5f4b123456', '60c72b2f9b1e8a5f4b123457']; + const userList = [ + { + _id: new ObjectId(idList[0]), + username: 'testUser1', + createDate: new Date(), + }, + { + _id: new ObjectId(idList[1]), + username: 'testUser2', + createDate: new Date(), + }, + ]; + + jest + .spyOn(userRepository, 'find') + .mockResolvedValue(userList as unknown as User[]); + + const result = await service.getUserListByIds({ idList }); + + expect(userRepository.find).toHaveBeenCalledWith({ + where: { + _id: { + $in: idList.map((id) => new ObjectId(id)), + }, + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + select: ['_id', 'username', 'createDate'], + }); + expect(result).toEqual(userList); + }); }); diff --git a/server/src/modules/survey/__test/collaborator.controller.spec.ts b/server/src/modules/survey/__test/collaborator.controller.spec.ts index 79819401..0119aaa3 100644 --- a/server/src/modules/survey/__test/collaborator.controller.spec.ts +++ b/server/src/modules/survey/__test/collaborator.controller.spec.ts @@ -10,7 +10,13 @@ import { UserService } from 'src/modules/auth/services/user.service'; import { ObjectId } from 'mongodb'; import { SurveyMetaService } from '../services/surveyMeta.service'; import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; -import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; +import { + SURVEY_PERMISSION, + SURVEY_PERMISSION_DESCRIPTION, +} from 'src/enums/surveyPermission'; +import { BatchSaveCollaboratorDto } from '../dto/batchSaveCollaborator.dto'; +import { User } from 'src/models/user.entity'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; jest.mock('src/guards/authentication.guard'); jest.mock('src/guards/survey.guard'); @@ -21,6 +27,8 @@ describe('CollaboratorController', () => { let collaboratorService: CollaboratorService; let logger: Logger; let userService: UserService; + let surveyMetaService: SurveyMetaService; + let workspaceMemberServie: WorkspaceMemberService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -34,12 +42,18 @@ describe('CollaboratorController', () => { changeUserPermission: jest.fn(), deleteCollaborator: jest.fn(), getCollaborator: jest.fn(), + batchDeleteBySurveyId: jest.fn(), + batchCreate: jest.fn(), + batchDelete: jest.fn(), + updateById: jest.fn(), + batchSaveCollaborator: jest.fn(), }, }, { provide: Logger, useValue: { error: jest.fn(), + info: jest.fn(), }, }, { @@ -72,6 +86,10 @@ describe('CollaboratorController', () => { collaboratorService = module.get(CollaboratorService); logger = module.get(Logger); userService = module.get(UserService); + surveyMetaService = module.get(SurveyMetaService); + workspaceMemberServie = module.get( + WorkspaceMemberService, + ); }); it('should be defined', () => { @@ -115,6 +133,59 @@ describe('CollaboratorController', () => { HttpException, ); }); + + it('should throw an exception if user does not exist', async () => { + const reqBody: CreateCollaboratorDto = { + surveyId: 'surveyId', + userId: new ObjectId().toString(), + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + }; + const req = { + user: { _id: 'userId' }, + surveyMeta: { ownerId: new ObjectId().toString() }, + }; + + jest.spyOn(userService, 'getUserById').mockResolvedValue(null); + + await expect(controller.addCollaborator(reqBody, req)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an exception if user is the survey owner', async () => { + const userId = new ObjectId().toString(); + const reqBody: CreateCollaboratorDto = { + surveyId: 'surveyId', + userId: userId, + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + }; + const req = { user: { _id: 'userId' }, surveyMeta: { ownerId: userId } }; + + await expect(controller.addCollaborator(reqBody, req)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an exception if user is already a collaborator', async () => { + const userId = new ObjectId().toString(); + const reqBody: CreateCollaboratorDto = { + surveyId: 'surveyId', + userId: userId, + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + }; + const req = { + user: { _id: 'userId' }, + surveyMeta: { ownerId: new ObjectId().toString() }, + }; + + jest + .spyOn(collaboratorService, 'getCollaborator') + .mockResolvedValue({} as unknown as Collaborator); + + await expect(controller.addCollaborator(reqBody, req)).rejects.toThrow( + HttpException, + ); + }); }); describe('getSurveyCollaboratorList', () => { @@ -217,4 +288,229 @@ describe('CollaboratorController', () => { expect(logger.error).toHaveBeenCalledTimes(1); }); }); + + // 新增的测试方法 + describe('getPermissionList', () => { + it('should return the permission list', async () => { + const result = Object.values(SURVEY_PERMISSION_DESCRIPTION); + + const response = await controller.getPermissionList(); + + expect(response).toEqual({ + code: 200, + data: result, + }); + }); + }); + + describe('batchSaveCollaborator', () => { + it('should batch save collaborators successfully', async () => { + const userId0 = new ObjectId().toString(); + const userId1 = new ObjectId().toString(); + const existsCollaboratorId = new ObjectId().toString(); + const surveyId = new ObjectId().toString(); + const reqBody: BatchSaveCollaboratorDto = { + surveyId: surveyId, + collaborators: [ + { + userId: userId0, + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + }, + { + _id: existsCollaboratorId, + userId: userId1, + permissions: [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE], + }, + ], + }; + const req = { + user: { _id: 'requestUserId' }, + surveyMeta: { ownerId: 'ownerId' }, + }; + + const userList = [ + { _id: new ObjectId(userId0) }, + { _id: new ObjectId(userId1) }, + ]; + + jest + .spyOn(userService, 'getUserListByIds') + .mockResolvedValue(userList as unknown as User[]); + jest + .spyOn(collaboratorService, 'batchDelete') + .mockResolvedValue({ deletedCount: 1, acknowledged: true }); + jest + .spyOn(collaboratorService, 'batchCreate') + .mockResolvedValue([{}] as any); + jest.spyOn(collaboratorService, 'updateById').mockResolvedValue({}); + + const response = await controller.batchSaveCollaborator(reqBody, req); + + expect(response).toEqual({ + code: 200, + }); + expect(userService.getUserListByIds).toHaveBeenCalled(); + expect(collaboratorService.batchDelete).toHaveBeenCalled(); + expect(collaboratorService.batchCreate).toHaveBeenCalled(); + expect(collaboratorService.updateById).toHaveBeenCalled(); + }); + + it('should throw an exception if validation fails', async () => { + const reqBody: BatchSaveCollaboratorDto = { + surveyId: '', + collaborators: [ + { + userId: '', + permissions: [SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE], + }, + ], + }; + const req = { user: { _id: 'userId' } }; + + await expect( + controller.batchSaveCollaborator(reqBody, req), + ).rejects.toThrow(HttpException); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUserSurveyPermissions', () => { + it('should return owner permissions if user is the owner', async () => { + const req = { + user: { _id: new ObjectId(), username: 'owner' }, + }; + const query = { surveyId: 'surveyId' }; + const surveyMeta = { + ownerId: req.user._id.toString(), + owner: req.user.username, + workspaceId: 'workspaceId', + }; + + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + + const response = await controller.getUserSurveyPermissions(req, query); + + expect(response).toEqual({ + code: 200, + data: { + isOwner: true, + permissions: [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + ], + }, + }); + }); + + it('should return default permissions if user is a workspace member', async () => { + const req = { + user: { _id: new ObjectId(), username: 'user' }, + }; + const query = { surveyId: 'surveyId' }; + const surveyMeta = { + ownerId: 'ownerId', + owner: 'owner', + workspaceId: 'workspaceId', + }; + + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest.spyOn(workspaceMemberServie, 'findOne').mockResolvedValue({} as any); + + const response = await controller.getUserSurveyPermissions(req, query); + + expect(response).toEqual({ + code: 200, + data: { + isOwner: false, + permissions: [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + ], + }, + }); + }); + + it('should return collaborator permissions if user is a collaborator', async () => { + const req = { + user: { _id: new ObjectId(), username: 'user' }, + }; + const query = { surveyId: 'surveyId' }; + const surveyMeta = { + ownerId: 'ownerId', + owner: 'owner', + workspaceId: 'workspaceId', + }; + const collaborator = { + permissions: ['read', 'write'], + }; + + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest.spyOn(workspaceMemberServie, 'findOne').mockResolvedValue(null); + jest + .spyOn(collaboratorService, 'getCollaborator') + .mockResolvedValue(collaborator as Collaborator); + + const response = await controller.getUserSurveyPermissions(req, query); + + expect(response).toEqual({ + code: 200, + data: { + isOwner: false, + permissions: collaborator.permissions, + }, + }); + }); + + it('should return empty permissions if user has no permissions', async () => { + const req = { + user: { _id: new ObjectId(), username: 'user' }, + }; + const query = { surveyId: 'surveyId' }; + const surveyMeta = { + ownerId: 'ownerId', + owner: 'owner', + workspaceId: 'workspaceId', + }; + + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest.spyOn(workspaceMemberServie, 'findOne').mockResolvedValue(null); + jest + .spyOn(collaboratorService, 'getCollaborator') + .mockResolvedValue(null); + + const response = await controller.getUserSurveyPermissions(req, query); + + expect(response).toEqual({ + code: 200, + data: { + isOwner: false, + permissions: [], + }, + }); + }); + + it('should throw an exception if survey does not exist', async () => { + const req = { user: { _id: 'userId' } }; + const query = { surveyId: 'nonexistentSurveyId' }; + + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValueOnce(null); + + await expect( + controller.getUserSurveyPermissions(req, query), + ).rejects.toThrow(HttpException); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts index a3222029..a2ca640f 100644 --- a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts +++ b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts @@ -14,6 +14,7 @@ import { Logger } from 'src/logger'; import { UserService } from 'src/modules/auth/services/user.service'; import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin'; import { AuthService } from 'src/modules/auth/services/auth.service'; +import { HttpException } from 'src/exceptions/httpException'; jest.mock('../services/dataStatistic.service'); jest.mock('../services/surveyMeta.service'); @@ -21,11 +22,13 @@ jest.mock('../../surveyResponse/services/responseScheme.service'); jest.mock('src/guards/authentication.guard'); jest.mock('src/guards/survey.guard'); -jest.mock('src/guards/workspace.guard'); describe('DataStatisticController', () => { let controller: DataStatisticController; let dataStatisticService: DataStatisticService; + let responseSchemaService: ResponseSchemaService; + let pluginManager: XiaojuSurveyPluginManager; + let logger: Logger; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -64,9 +67,14 @@ describe('DataStatisticController', () => { controller = module.get(DataStatisticController); dataStatisticService = module.get(DataStatisticService); - const pluginManager = module.get( + responseSchemaService = module.get( + ResponseSchemaService, + ); + pluginManager = module.get( XiaojuSurveyPluginManager, ); + logger = module.get(Logger); + pluginManager.registerPlugin( new ResponseSecurityPlugin('dataAesEncryptSecretKey'), ); @@ -82,6 +90,9 @@ describe('DataStatisticController', () => { const mockRequest = { query: { surveyId, + isDesensitive: false, + page: 1, + pageSize: 10, }, user: { username: 'testUser', @@ -105,13 +116,13 @@ describe('DataStatisticController', () => { }; jest - .spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId') + .spyOn(responseSchemaService, 'getResponseSchemaByPageId') .mockResolvedValueOnce({} as any); jest .spyOn(dataStatisticService, 'getDataTable') .mockResolvedValueOnce(mockDataTable); - const result = await controller.data(mockRequest.query, {}); + const result = await controller.data(mockRequest.query, mockRequest); expect(result).toEqual({ code: 200, @@ -125,6 +136,8 @@ describe('DataStatisticController', () => { query: { surveyId, isDesensitive: true, + page: 1, + pageSize: 10, }, user: { username: 'testUser', @@ -146,19 +159,499 @@ describe('DataStatisticController', () => { { difTime: '0.5', createDate: '2024-02-11', data123: '13800000000' }, ], }; + jest - .spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId') + .spyOn(responseSchemaService, 'getResponseSchemaByPageId') .mockResolvedValueOnce({} as any); jest .spyOn(dataStatisticService, 'getDataTable') .mockResolvedValueOnce(mockDataTable); - const result = await controller.data(mockRequest.query, {}); + const result = await controller.data(mockRequest.query, mockRequest); expect(result).toEqual({ code: 200, data: mockDataTable, }); }); + + it('should throw an exception if validation fails', async () => { + const mockRequest = { + query: { + surveyId: '', + }, + user: { + username: 'testUser', + }, + }; + + await expect( + controller.data(mockRequest.query, mockRequest), + ).rejects.toThrow(HttpException); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + }); + + describe('aggregationStatis', () => { + it('should return aggregation statistics', async () => { + const mockRequest = { + query: { + surveyId: new ObjectId().toString(), + }, + }; + + const mockResponseSchema = { + _id: new ObjectId('6659c3283b1cb279bc2e2b0c'), + curStatus: { + status: 'published', + date: 1717159136024, + }, + statusList: [ + { + status: 'published', + date: 1717158851823, + }, + ], + createDate: 1717158851823, + updateDate: 1717159136025, + title: '问卷调研', + surveyPath: 'ZdGNzTTR', + code: { + bannerConf: { + titleConfig: { + mainTitle: + '

欢迎填写问卷

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

', + subTitle: '', + applyTitle: '', + }, + bannerConfig: { + bgImage: '/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp', + bgImageAllowJump: false, + bgImageJumpLink: '', + videoLink: '', + postImg: '', + }, + }, + baseConf: { + begTime: '2024-05-31 20:31:36', + endTime: '2034-05-31 20:31:36', + language: 'chinese', + showVoteProcess: 'allow', + tLimit: 0, + answerBegTime: '00:00:00', + answerEndTime: '23:59:59', + answerLimitTime: 0, + }, + bottomConf: { + logoImage: '/imgs/Logo.webp', + logoImageWidth: '60%', + }, + skinConf: { + backgroundConf: { + color: '#fff', + }, + themeConf: { + color: '#ffa600', + }, + contentConf: { + opacity: 100, + }, + skinColor: '#4a4c5b', + inputBgColor: '#ffffff', + }, + submitConf: { + submitTitle: '提交', + msgContent: { + msg_200: '提交成功', + msg_9001: '您来晚了,感谢支持问卷~', + msg_9002: '请勿多次提交!', + msg_9003: '您来晚了,已经满额!', + msg_9004: '提交失败!', + }, + confirmAgain: { + is_again: true, + again_text: '确认要提交吗?', + }, + link: '', + }, + logicConf: { + showLogicConf: [], + }, + dataConf: { + dataList: [ + { + isRequired: true, + showIndex: true, + showType: true, + showSpliter: true, + type: 'radio', + placeholderDesc: '', + field: 'data515', + title: '标题2', + placeholder: '', + randomSort: false, + checked: false, + minNum: '', + maxNum: '', + options: [ + { + text: '选项1', + imageUrl: '', + others: false, + mustOthers: false, + othersKey: '', + placeholderDesc: '', + hash: '115019', + }, + { + text: '选项2', + imageUrl: '', + others: false, + mustOthers: false, + othersKey: '', + placeholderDesc: '', + hash: '115020', + }, + ], + importKey: 'single', + importData: '', + cOption: '', + cOptions: [], + star: 5, + exclude: false, + textRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '500', + value: 500, + }, + }, + }, + { + field: 'data893', + showIndex: true, + showType: true, + showSpliter: true, + type: 'checkbox', + placeholderDesc: '', + sLimit: 0, + mhLimit: 0, + title: '标题2', + placeholder: '', + valid: '', + isRequired: true, + randomSort: false, + showLeftNum: true, + innerRandom: false, + checked: false, + selectType: 'radio', + sortWay: 'v', + noNps: '', + minNum: '', + maxNum: '', + starStyle: 'star', + starMin: 1, + starMax: 5, + min: 0, + max: 10, + minMsg: '极不满意', + maxMsg: '十分满意', + rangeConfig: {}, + options: [ + { + text: '选项1', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '466671', + }, + { + text: '选项2', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '095415', + }, + ], + star: 5, + optionOrigin: '', + originType: 'selected', + matrixOptionsRely: '', + numberRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '1000', + value: 1000, + }, + }, + textRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '500', + value: 500, + }, + }, + }, + { + field: 'data820', + showIndex: true, + showType: true, + showSpliter: true, + type: 'radio-nps', + placeholderDesc: '', + sLimit: 0, + mhLimit: 0, + title: '标题3', + placeholder: '', + valid: '', + isRequired: true, + randomSort: false, + showLeftNum: true, + innerRandom: false, + checked: false, + selectType: 'radio', + sortWay: 'v', + noNps: '', + minNum: '', + maxNum: '', + starStyle: 'star', + starMin: 1, + starMax: 5, + min: 0, + max: 10, + minMsg: '极不满意', + maxMsg: '十分满意', + rangeConfig: {}, + options: [ + { + text: '选项1', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '268884', + }, + { + text: '选项2', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '371166', + }, + ], + star: 5, + optionOrigin: '', + originType: 'selected', + matrixOptionsRely: '', + numberRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '1000', + value: 1000, + }, + }, + textRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '500', + value: 500, + }, + }, + }, + { + field: 'data549', + showIndex: true, + showType: true, + showSpliter: true, + type: 'radio-star', + placeholderDesc: '', + sLimit: 0, + mhLimit: 0, + title: '标题4', + placeholder: '', + valid: '', + isRequired: true, + randomSort: false, + showLeftNum: true, + innerRandom: false, + checked: false, + selectType: 'radio', + sortWay: 'v', + noNps: '', + minNum: '', + maxNum: '', + starStyle: 'star', + starMin: 1, + starMax: 5, + min: 0, + max: 10, + minMsg: '极不满意', + maxMsg: '十分满意', + rangeConfig: {}, + options: [ + { + text: '选项1', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '274183', + }, + { + text: '选项2', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '842967', + }, + ], + star: 5, + optionOrigin: '', + originType: 'selected', + matrixOptionsRely: '', + numberRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '1000', + value: 1000, + }, + }, + textRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '500', + value: 500, + }, + }, + }, + ], + }, + }, + pageId: '6659c3283b1cb279bc2e2b0c', + }; + + const mockAggregationResult = [ + { + field: 'data515', + data: { + aggregation: [ + { + id: '115019', + count: 1, + }, + { + id: '115020', + count: 1, + }, + ], + submitionCount: 2, + }, + }, + { + field: 'data893', + data: { + aggregation: [ + { + id: '466671', + count: 2, + }, + { + id: '095415', + count: 1, + }, + ], + submitionCount: 2, + }, + }, + { + field: 'data820', + data: { + aggregation: [ + { + id: '8', + count: 1, + }, + ], + submitionCount: 1, + }, + }, + { + field: 'data549', + data: { + aggregation: [ + { + id: '5', + count: 1, + }, + ], + submitionCount: 1, + }, + }, + ]; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPageId') + .mockResolvedValueOnce(mockResponseSchema as any); + jest + .spyOn(dataStatisticService, 'aggregationStatis') + .mockResolvedValueOnce(mockAggregationResult); + + const result = await controller.aggregationStatis(mockRequest.query); + + expect(result).toEqual({ + code: 200, + data: expect.any(Array), + }); + }); + + it('should throw an exception if validation fails', async () => { + const mockRequest = { + query: { + surveyId: '', + }, + }; + + await expect( + controller.aggregationStatis(mockRequest.query), + ).rejects.toThrow(HttpException); + }); + + it('should return empty data if response schema does not exist', async () => { + const mockRequest = { + query: { + surveyId: new ObjectId().toString(), + }, + }; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPageId') + .mockResolvedValueOnce(null); + + const result = await controller.aggregationStatis(mockRequest.query); + + expect(result).toEqual({ + code: 200, + data: [], + }); + }); }); }); diff --git a/server/src/modules/survey/__test/dataStatistic.service.spec.ts b/server/src/modules/survey/__test/dataStatistic.service.spec.ts index 830c7b59..1130364f 100644 --- a/server/src/modules/survey/__test/dataStatistic.service.spec.ts +++ b/server/src/modules/survey/__test/dataStatistic.service.spec.ts @@ -197,7 +197,6 @@ describe('DataStatisticService', () => { data413_3: expect.any(String), data413: expect.any(Number), data863: expect.any(String), - data413_custom: expect.any(String), difTime: expect.any(String), createDate: expect.any(String), }), @@ -310,4 +309,161 @@ describe('DataStatisticService', () => { ); }); }); + + describe('aggregationStatis', () => { + it('should return correct aggregation data', async () => { + const surveyId = '65afc62904d5db18534c0f78'; + const mockAggregationResult = { + data515: [ + { + count: 1, + data: { + data515: '115019', + }, + }, + { + count: 1, + data: { + data515: '115020', + }, + }, + ], + data893: [ + { + count: 1, + data: { + data893: ['466671'], + }, + }, + { + count: 1, + data: { + data893: ['466671', '095415'], + }, + }, + ], + data820: [ + { + count: 1, + data: { + data820: 8, + }, + }, + ], + data549: [ + { + count: 1, + data: { + data549: 5, + }, + }, + ], + }; + + const fieldList = Object.keys(mockAggregationResult); + + jest.spyOn(surveyResponseRepository, 'aggregate').mockReturnValue({ + next: jest.fn().mockResolvedValue(mockAggregationResult), + } as any); + + const result = await service.aggregationStatis({ + surveyId, + fieldList, + }); + + expect(result).toEqual( + expect.arrayContaining([ + { + field: 'data515', + data: { + aggregation: [ + { + id: '115019', + count: 1, + }, + { + id: '115020', + count: 1, + }, + ], + submitionCount: 2, + }, + }, + { + field: 'data893', + data: { + aggregation: [ + { + id: '466671', + count: 2, + }, + { + id: '095415', + count: 1, + }, + ], + submitionCount: 2, + }, + }, + { + field: 'data820', + data: { + aggregation: [ + { + id: '8', + count: 1, + }, + ], + submitionCount: 1, + }, + }, + { + field: 'data549', + data: { + aggregation: [ + { + id: '5', + count: 1, + }, + ], + submitionCount: 1, + }, + }, + ]), + ); + }); + + it('should return empty aggregation data when no responses', async () => { + const surveyId = '65afc62904d5db18534c0f78'; + const fieldList = ['data458', 'data515']; + + jest.spyOn(surveyResponseRepository, 'aggregate').mockReturnValue({ + next: jest.fn().mockResolvedValue({}), + } as any); + + const result = await service.aggregationStatis({ + surveyId, + fieldList, + }); + + expect(result).toEqual( + expect.arrayContaining([ + { + field: 'data458', + data: { + aggregation: [], + submitionCount: 0, + }, + }, + { + field: 'data515', + data: { + aggregation: [], + submitionCount: 0, + }, + }, + ]), + ); + }); + }); }); diff --git a/server/src/modules/survey/controllers/collaborator.controller.ts b/server/src/modules/survey/controllers/collaborator.controller.ts index 35fc1d11..08c97068 100644 --- a/server/src/modules/survey/controllers/collaborator.controller.ts +++ b/server/src/modules/survey/controllers/collaborator.controller.ts @@ -319,6 +319,7 @@ export class CollaboratorController { const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId }); if (!surveyMeta) { + this.logger.error(`问卷不存在: ${surveyId}`, { req }); throw new HttpException('问卷不存在', EXCEPTION_CODE.SURVEY_NOT_FOUND); } diff --git a/server/src/modules/survey/controllers/dataStatistic.controller.ts b/server/src/modules/survey/controllers/dataStatistic.controller.ts index a0b1754c..452b4fc6 100644 --- a/server/src/modules/survey/controllers/dataStatistic.controller.ts +++ b/server/src/modules/survey/controllers/dataStatistic.controller.ts @@ -20,6 +20,8 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { Logger } from 'src/logger'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { AggregationStatisDto } from '../dto/aggregationStatis.dto'; +import { handleAggretionData } from '../utils'; @ApiTags('survey') @ApiBearerAuth() @@ -80,4 +82,50 @@ export class DataStatisticController { }, }; } + + @Get('/aggregationStatis') + @HttpCode(200) + @UseGuards(Authentication) + async aggregationStatis(@Query() queryInfo: AggregationStatisDto) { + // 聚合统计 + const { value, error } = AggregationStatisDto.validate(queryInfo); + if (error) { + throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const responseSchema = + await this.responseSchemaService.getResponseSchemaByPageId( + value.surveyId, + ); + if (!responseSchema) { + return { + code: 200, + data: [], + }; + } + const allowQuestionType = [ + 'radio', + 'checkbox', + 'binary-choice', + 'radio-star', + 'radio-nps', + 'vote', + ]; + const fieldList = responseSchema.code.dataConf.dataList + .filter((item) => allowQuestionType.includes(item.type)) + .map((item) => item.field); + const dataMap = responseSchema.code.dataConf.dataList.reduce((pre, cur) => { + pre[cur.field] = cur; + return pre; + }, {}); + const res = await this.dataStatisticService.aggregationStatis({ + surveyId: value.surveyId, + fieldList, + }); + return { + code: 200, + data: res.map((item) => { + return handleAggretionData({ item, dataMap }); + }), + }; + } } diff --git a/server/src/modules/survey/dto/aggregationStatis.dto.ts b/server/src/modules/survey/dto/aggregationStatis.dto.ts new file mode 100644 index 00000000..a8f747a7 --- /dev/null +++ b/server/src/modules/survey/dto/aggregationStatis.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class AggregationStatisDto { + @ApiProperty({ description: '问卷id', required: true }) + surveyId: string; + + static validate(data) { + return Joi.object({ + surveyId: Joi.string().required(), + }).validate(data); + } +} diff --git a/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts b/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts index 1ae04787..119fe584 100644 --- a/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts +++ b/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts @@ -3,7 +3,10 @@ import Joi from 'joi'; import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; export class CollaboratorDto { - @ApiProperty({ description: '用户id', required: false }) + @ApiProperty({ description: '协作id', required: false }) + _id?: string; + + @ApiProperty({ description: '用户id', required: true }) userId: string; @ApiProperty({ @@ -16,7 +19,7 @@ export class CollaboratorDto { SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, ], }) - permissions: Array; + permissions: Array; } export class BatchSaveCollaboratorDto { diff --git a/server/src/modules/survey/services/dataStatistic.service.ts b/server/src/modules/survey/services/dataStatistic.service.ts index cd958a7f..ce31fed6 100644 --- a/server/src/modules/survey/services/dataStatistic.service.ts +++ b/server/src/modules/survey/services/dataStatistic.service.ts @@ -7,7 +7,7 @@ import moment from 'moment'; import { keyBy } from 'lodash'; import { DataItem } from 'src/interfaces/survey'; import { ResponseSchema } from 'src/models/responseSchema.entity'; -import { getListHeadByDataList } from '../utils'; +import { getListHeadByDataList, transformAndMergeArrayFields } from '../utils'; @Injectable() export class DataStatisticService { private radioType = ['radio-star', 'radio-nps']; @@ -101,4 +101,62 @@ export class DataStatisticService { listBody, }; } + + async aggregationStatis({ surveyId, fieldList }) { + const $facet = fieldList.reduce((pre, cur) => { + const $match = { $match: { [`data.${cur}`]: { $nin: [[], '', null] } } }; + const $group = { $group: { _id: `$data.${cur}`, count: { $sum: 1 } } }; + const $project = { + $project: { + _id: 0, + count: 1, + secretKeys: 1, + sensitiveKeys: 1, + [`data.${cur}`]: '$_id', + }, + }; + pre[cur] = [$match, $group, $project]; + return pre; + }, {}); + const aggregation = this.surveyResponseRepository.aggregate( + [ + { + $match: { + pageId: surveyId, + 'curStatus.status': { + $ne: 'removed', + }, + }, + }, + { $facet }, + ], + { maxTimeMS: 30000, allowDiskUse: true }, + ); + const res = await aggregation.next(); + const submitionCountMap: Record = {}; + for (const field in res) { + let count = 0; + if (Array.isArray(res[field])) { + for (const optionItem of res[field]) { + count += optionItem.count; + } + } + submitionCountMap[field] = count; + } + const transformedData = transformAndMergeArrayFields(res); + return fieldList.map((field) => { + return { + field, + data: { + aggregation: (transformedData?.[field] || []).map((optionItem) => { + return { + id: optionItem.data[field], + count: optionItem.count, + }; + }), + submitionCount: submitionCountMap?.[field] || 0, + }, + }; + }); + } } diff --git a/server/src/modules/survey/utils/index.ts b/server/src/modules/survey/utils/index.ts index fd988f55..ced78f14 100644 --- a/server/src/modules/survey/utils/index.ts +++ b/server/src/modules/survey/utils/index.ts @@ -66,3 +66,177 @@ export function getListHeadByDataList(dataList) { }); return listHead; } + +export function transformAndMergeArrayFields(data) { + const transformedData = {}; + + for (const key in data) { + const valueMap: Record = {}; + + for (const entry of data[key]) { + const nestedDataKey = Object.keys(entry.data)[0]; + const nestedDataValue = entry.data[nestedDataKey]; + + if (Array.isArray(nestedDataValue)) { + for (const value of nestedDataValue) { + if (!valueMap[value]) { + valueMap[value] = 0; + } + valueMap[value] += entry.count; + } + } else { + if (!valueMap[nestedDataValue]) { + valueMap[nestedDataValue] = 0; + } + valueMap[nestedDataValue] += entry.count; + } + } + + transformedData[key] = Object.keys(valueMap).map((value) => ({ + count: valueMap[value], + data: { + [key]: value, + }, + })); + } + + return transformedData; +} + +export function handleAggretionData({ dataMap, item }) { + const type = dataMap[item.field].type; + const aggregationMap = item.data.aggregation.reduce((pre, cur) => { + pre[cur.id] = cur; + return pre; + }, {}); + if (['radio', 'checkbox', 'vote', 'binary-choice'].includes(type)) { + return { + ...item, + title: dataMap[item.field].title, + type: dataMap[item.field].type, + data: { + ...item.data, + aggregation: dataMap[item.field].options.map((optionItem) => { + return { + id: optionItem.hash, + text: optionItem.text, + count: aggregationMap[optionItem.hash]?.count || 0, + }; + }), + }, + }; + } else if (['radio-star', 'radio-nps'].includes(type)) { + const summary: Record = {}; + const average = getAverage({ aggregation: item.data.aggregation }); + const median = getMedian({ aggregation: item.data.aggregation }); + const variance = getVariance({ + aggregation: item.data.aggregation, + average, + }); + summary['average'] = average; + summary['median'] = median; + summary['variance'] = variance; + if (type === 'radio-nps') { + summary['nps'] = getNps({ aggregation: item.data.aggregation }); + } + const range = type === 'radio-nps' ? [0, 10] : [1, 5]; + const arr = []; + for (let i = range[0]; i <= range[1]; i++) { + arr.push(i); + } + return { + ...item, + title: dataMap[item.field].title, + type: dataMap[item.field].type, + data: { + aggregation: arr.map((item) => { + const num = item.toString(); + return { + text: num, + id: num, + count: aggregationMap?.[num]?.count || 0, + }; + }), + submitionCount: item.data.submitionCount, + summary, + }, + }; + } else { + return { + ...item, + title: dataMap[item.field].title, + type: dataMap[item.field].type, + }; + } +} + +function getAverage({ aggregation }) { + const { sum, count } = aggregation.reduce( + (pre, cur) => { + const num = parseInt(cur.id); + pre.sum += num * cur.count; + pre.count += cur.count; + return pre; + }, + { sum: 0, count: 0 }, + ); + if (count === 0) { + return 0; + } + return (sum / count).toFixed(2); +} + +function getMedian({ aggregation }) { + const sortedArr = aggregation.sort((a, b) => { + return parseInt(a.id) - parseInt(b.id); + }); + const resArr = []; + for (const item of sortedArr) { + const tmp = new Array(item.count).fill(parseInt(item.id)); + resArr.push(...tmp); + } + if (resArr.length === 0) { + return 0; + } + if (resArr.length % 2 === 1) { + const midIndex = Math.floor(resArr.length / 2); + return resArr[midIndex].toFixed(2); + } + const rightIndex = resArr.length / 2; + const leftIndex = rightIndex - 1; + return ((resArr[leftIndex] + resArr[rightIndex]) / 2).toFixed(2); +} + +function getVariance({ aggregation, average }) { + const { sum, count } = aggregation.reduce( + (pre, cur) => { + const sub = Number(cur.id) - average; + pre.sum += sub * sub; + pre.count += cur.count; + return pre; + }, + { sum: 0, count: 0 }, + ); + if (count === 0 || count === 1) { + return '0.00'; + } + return (sum / (count - 1)).toFixed(2); +} + +function getNps({ aggregation }) { + // 净推荐值(NPS)=(推荐者数/总样本数)×100%-(贬损者数/总样本数)×100% + // 0~10分举例子:推荐者(9-10分);被动者(7-8分);贬损者(0-6分) + let recommand = 0, + derogatory = 0, + total = 0; + for (const item of aggregation) { + const num = parseInt(item.id); + if (num >= 9) { + recommand += item.count; + } else if (num <= 6) { + derogatory += item.count; + } + total += item.count; + } + return ((recommand / total - derogatory / total) * 100).toFixed(2) + '%'; +}