feat: 新增分题统计相关功能 (#275)

This commit is contained in:
luch 2024-06-21 16:24:49 +08:00 committed by sudoooooo
parent 053d9751c3
commit 0b42899347
11 changed files with 1443 additions and 16 deletions

View File

@ -11,6 +11,7 @@ import { NoPermissionException } from 'src/exceptions/noPermissionException';
import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { WorkspaceMember } from 'src/models/workspaceMember.entity'; import { WorkspaceMember } from 'src/models/workspaceMember.entity';
import { Collaborator } from 'src/models/collaborator.entity'; import { Collaborator } from 'src/models/collaborator.entity';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
describe('SurveyGuard', () => { describe('SurveyGuard', () => {
let guard: 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 context = createMockExecutionContext();
const surveyMeta = { owner: 'testUser', workspaceId: null }; const surveyMeta = { owner: 'testUser', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId');
@ -108,7 +121,35 @@ describe('SurveyGuard', () => {
expect(result).toBe(true); 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 context = createMockExecutionContext();
const surveyMeta = { owner: 'anotherUser', workspaceId: null }; const surveyMeta = { owner: 'anotherUser', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId'); 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 { function createMockExecutionContext(): ExecutionContext {
return { return {
switchToHttp: jest.fn().mockReturnValue({ switchToHttp: jest.fn().mockReturnValue({

View File

@ -6,6 +6,7 @@ import { User } from 'src/models/user.entity';
import { HttpException } from 'src/exceptions/httpException'; import { HttpException } from 'src/exceptions/httpException';
import { hash256 } from 'src/utils/hash256'; import { hash256 } from 'src/utils/hash256';
import { RECORD_STATUS } from 'src/enums'; import { RECORD_STATUS } from 'src/enums';
import { ObjectId } from 'mongodb';
describe('UserService', () => { describe('UserService', () => {
let service: UserService; let service: UserService;
@ -21,6 +22,7 @@ describe('UserService', () => {
create: jest.fn(), create: jest.fn(),
save: jest.fn(), save: jest.fn(),
findOne: 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 () => { it('should create a user', async () => {
const userInfo = { const userInfo = {
username: 'testUser', username: 'testUser',
@ -102,7 +108,7 @@ describe('UserService', () => {
expect(user).toEqual({ ...userInfo, password: hashedPassword }); 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 = { const userInfo = {
username: 'nonExistingUser', username: 'nonExistingUser',
password: 'nonExistingPassword', password: 'nonExistingPassword',
@ -129,7 +135,8 @@ describe('UserService', () => {
const userInfo = { const userInfo = {
username: username, username: username,
password: 'existingPassword', password: 'existingPassword',
} as User; curStatus: { status: 'ACTIVE' },
} as unknown as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo); jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo);
@ -137,10 +144,129 @@ describe('UserService', () => {
expect(userRepository.findOne).toHaveBeenCalledWith({ expect(userRepository.findOne).toHaveBeenCalledWith({
where: { where: {
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
username: username, username: username,
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
}, },
}); });
expect(user).toEqual(userInfo); 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);
});
}); });

View File

@ -10,7 +10,13 @@ import { UserService } from 'src/modules/auth/services/user.service';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { SurveyMetaService } from '../services/surveyMeta.service'; import { SurveyMetaService } from '../services/surveyMeta.service';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.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/authentication.guard');
jest.mock('src/guards/survey.guard'); jest.mock('src/guards/survey.guard');
@ -21,6 +27,8 @@ describe('CollaboratorController', () => {
let collaboratorService: CollaboratorService; let collaboratorService: CollaboratorService;
let logger: Logger; let logger: Logger;
let userService: UserService; let userService: UserService;
let surveyMetaService: SurveyMetaService;
let workspaceMemberServie: WorkspaceMemberService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -34,12 +42,18 @@ describe('CollaboratorController', () => {
changeUserPermission: jest.fn(), changeUserPermission: jest.fn(),
deleteCollaborator: jest.fn(), deleteCollaborator: jest.fn(),
getCollaborator: jest.fn(), getCollaborator: jest.fn(),
batchDeleteBySurveyId: jest.fn(),
batchCreate: jest.fn(),
batchDelete: jest.fn(),
updateById: jest.fn(),
batchSaveCollaborator: jest.fn(),
}, },
}, },
{ {
provide: Logger, provide: Logger,
useValue: { useValue: {
error: jest.fn(), error: jest.fn(),
info: jest.fn(),
}, },
}, },
{ {
@ -72,6 +86,10 @@ describe('CollaboratorController', () => {
collaboratorService = module.get<CollaboratorService>(CollaboratorService); collaboratorService = module.get<CollaboratorService>(CollaboratorService);
logger = module.get<Logger>(Logger); logger = module.get<Logger>(Logger);
userService = module.get<UserService>(UserService); userService = module.get<UserService>(UserService);
surveyMetaService = module.get<SurveyMetaService>(SurveyMetaService);
workspaceMemberServie = module.get<WorkspaceMemberService>(
WorkspaceMemberService,
);
}); });
it('should be defined', () => { it('should be defined', () => {
@ -115,6 +133,59 @@ describe('CollaboratorController', () => {
HttpException, 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', () => { describe('getSurveyCollaboratorList', () => {
@ -217,4 +288,229 @@ describe('CollaboratorController', () => {
expect(logger.error).toHaveBeenCalledTimes(1); 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);
});
});
}); });

View File

@ -14,6 +14,7 @@ import { Logger } from 'src/logger';
import { UserService } from 'src/modules/auth/services/user.service'; import { UserService } from 'src/modules/auth/services/user.service';
import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin'; import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin';
import { AuthService } from 'src/modules/auth/services/auth.service'; import { AuthService } from 'src/modules/auth/services/auth.service';
import { HttpException } from 'src/exceptions/httpException';
jest.mock('../services/dataStatistic.service'); jest.mock('../services/dataStatistic.service');
jest.mock('../services/surveyMeta.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/authentication.guard');
jest.mock('src/guards/survey.guard'); jest.mock('src/guards/survey.guard');
jest.mock('src/guards/workspace.guard');
describe('DataStatisticController', () => { describe('DataStatisticController', () => {
let controller: DataStatisticController; let controller: DataStatisticController;
let dataStatisticService: DataStatisticService; let dataStatisticService: DataStatisticService;
let responseSchemaService: ResponseSchemaService;
let pluginManager: XiaojuSurveyPluginManager;
let logger: Logger;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -64,9 +67,14 @@ describe('DataStatisticController', () => {
controller = module.get<DataStatisticController>(DataStatisticController); controller = module.get<DataStatisticController>(DataStatisticController);
dataStatisticService = dataStatisticService =
module.get<DataStatisticService>(DataStatisticService); module.get<DataStatisticService>(DataStatisticService);
const pluginManager = module.get<XiaojuSurveyPluginManager>( responseSchemaService = module.get<ResponseSchemaService>(
ResponseSchemaService,
);
pluginManager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager, XiaojuSurveyPluginManager,
); );
logger = module.get<Logger>(Logger);
pluginManager.registerPlugin( pluginManager.registerPlugin(
new ResponseSecurityPlugin('dataAesEncryptSecretKey'), new ResponseSecurityPlugin('dataAesEncryptSecretKey'),
); );
@ -82,6 +90,9 @@ describe('DataStatisticController', () => {
const mockRequest = { const mockRequest = {
query: { query: {
surveyId, surveyId,
isDesensitive: false,
page: 1,
pageSize: 10,
}, },
user: { user: {
username: 'testUser', username: 'testUser',
@ -105,13 +116,13 @@ describe('DataStatisticController', () => {
}; };
jest jest
.spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId') .spyOn(responseSchemaService, 'getResponseSchemaByPageId')
.mockResolvedValueOnce({} as any); .mockResolvedValueOnce({} as any);
jest jest
.spyOn(dataStatisticService, 'getDataTable') .spyOn(dataStatisticService, 'getDataTable')
.mockResolvedValueOnce(mockDataTable); .mockResolvedValueOnce(mockDataTable);
const result = await controller.data(mockRequest.query, {}); const result = await controller.data(mockRequest.query, mockRequest);
expect(result).toEqual({ expect(result).toEqual({
code: 200, code: 200,
@ -125,6 +136,8 @@ describe('DataStatisticController', () => {
query: { query: {
surveyId, surveyId,
isDesensitive: true, isDesensitive: true,
page: 1,
pageSize: 10,
}, },
user: { user: {
username: 'testUser', username: 'testUser',
@ -146,19 +159,499 @@ describe('DataStatisticController', () => {
{ difTime: '0.5', createDate: '2024-02-11', data123: '13800000000' }, { difTime: '0.5', createDate: '2024-02-11', data123: '13800000000' },
], ],
}; };
jest jest
.spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId') .spyOn(responseSchemaService, 'getResponseSchemaByPageId')
.mockResolvedValueOnce({} as any); .mockResolvedValueOnce({} as any);
jest jest
.spyOn(dataStatisticService, 'getDataTable') .spyOn(dataStatisticService, 'getDataTable')
.mockResolvedValueOnce(mockDataTable); .mockResolvedValueOnce(mockDataTable);
const result = await controller.data(mockRequest.query, {}); const result = await controller.data(mockRequest.query, mockRequest);
expect(result).toEqual({ expect(result).toEqual({
code: 200, code: 200,
data: mockDataTable, 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:
'<h3 style="text-align: center">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style="color: rgb(204, 0, 0)">期待您的参与!</span></p>',
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: [],
});
});
}); });
}); });

View File

@ -197,7 +197,6 @@ describe('DataStatisticService', () => {
data413_3: expect.any(String), data413_3: expect.any(String),
data413: expect.any(Number), data413: expect.any(Number),
data863: expect.any(String), data863: expect.any(String),
data413_custom: expect.any(String),
difTime: expect.any(String), difTime: expect.any(String),
createDate: 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,
},
},
]),
);
});
});
}); });

View File

@ -319,6 +319,7 @@ export class CollaboratorController {
const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId }); const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId });
if (!surveyMeta) { if (!surveyMeta) {
this.logger.error(`问卷不存在: ${surveyId}`, { req });
throw new HttpException('问卷不存在', EXCEPTION_CODE.SURVEY_NOT_FOUND); throw new HttpException('问卷不存在', EXCEPTION_CODE.SURVEY_NOT_FOUND);
} }

View File

@ -20,6 +20,8 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { Logger } from 'src/logger'; import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException'; import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { AggregationStatisDto } from '../dto/aggregationStatis.dto';
import { handleAggretionData } from '../utils';
@ApiTags('survey') @ApiTags('survey')
@ApiBearerAuth() @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 });
}),
};
}
} }

View File

@ -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);
}
}

View File

@ -3,7 +3,10 @@ import Joi from 'joi';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
export class CollaboratorDto { export class CollaboratorDto {
@ApiProperty({ description: '用户id', required: false }) @ApiProperty({ description: '协作id', required: false })
_id?: string;
@ApiProperty({ description: '用户id', required: true })
userId: string; userId: string;
@ApiProperty({ @ApiProperty({
@ -16,7 +19,7 @@ export class CollaboratorDto {
SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE,
], ],
}) })
permissions: Array<number>; permissions: Array<string>;
} }
export class BatchSaveCollaboratorDto { export class BatchSaveCollaboratorDto {

View File

@ -7,7 +7,7 @@ import moment from 'moment';
import { keyBy } from 'lodash'; import { keyBy } from 'lodash';
import { DataItem } from 'src/interfaces/survey'; import { DataItem } from 'src/interfaces/survey';
import { ResponseSchema } from 'src/models/responseSchema.entity'; import { ResponseSchema } from 'src/models/responseSchema.entity';
import { getListHeadByDataList } from '../utils'; import { getListHeadByDataList, transformAndMergeArrayFields } from '../utils';
@Injectable() @Injectable()
export class DataStatisticService { export class DataStatisticService {
private radioType = ['radio-star', 'radio-nps']; private radioType = ['radio-star', 'radio-nps'];
@ -101,4 +101,62 @@ export class DataStatisticService {
listBody, 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<string, number> = {};
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,
},
};
});
}
} }

View File

@ -66,3 +66,177 @@ export function getListHeadByDataList(dataList) {
}); });
return listHead; return listHead;
} }
export function transformAndMergeArrayFields(data) {
const transformedData = {};
for (const key in data) {
const valueMap: Record<string, number> = {};
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<string, any> = {};
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%
// 010分举例子推荐者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) + '%';
}