feat: 完善单测 (#71)

This commit is contained in:
luch 2024-03-14 21:50:57 +08:00 committed by GitHub
parent 292082e3b0
commit 8d29865ff0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 3580 additions and 760 deletions

View File

@ -1,6 +1,6 @@
XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey
XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017 XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017
XIAOJU_SURVEY_MONGO_AUTH_SOURCE= XIAOJU_SURVEY_MONGO_AUTH_SOURCE=admin
XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY=dataAesEncryptSecretKey XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY=dataAesEncryptSecretKey

View File

@ -5,11 +5,11 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>问卷管理端</title> <title>问卷管理端</title>
<link rel="stylesheet" href="./commom.css"> <link rel="stylesheet" href="/commom.css">
</head> </head>
<body> <body>
<div id="main"> <div id="main">
<img src="./nodata.png" alt=""> <img src="/nodata.png" alt="">
<p class="title">暂无数据</p> <p class="title">暂无数据</p>
</div> </div>
</body> </body>

View File

@ -5,11 +5,11 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>问卷管理端</title> <title>问卷管理端</title>
<link rel="stylesheet" href="./commom.css"> <link rel="stylesheet" href="/commom.css">
</head> </head>
<body> <body>
<div id="main"> <div id="main">
<img src="./nodata.png" alt=""> <img src="/nodata.png" alt="">
<p class="title">暂无数据</p> <p class="title">暂无数据</p>
</div> </div>
</body> </body>

View File

@ -5,11 +5,11 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>问卷投放端</title> <title>问卷投放端</title>
<link rel="stylesheet" href="./commom.css"> <link rel="stylesheet" href="/commom.css">
</head> </head>
<body> <body>
<div id="main"> <div id="main">
<img src="./nodata.png" alt=""> <img src="/nodata.png" alt="">
<p class="title">暂无数据</p> <p class="title">暂无数据</p>
</div> </div>
</body> </body>

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
describe('AppController', () => {
let controller: AppController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AppController],
}).compile();
controller = module.get<AppController>(AppController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -46,7 +46,7 @@ import { Logger } from './logger';
const authSource = const authSource =
(await configService.get<string>( (await configService.get<string>(
'XIAOJU_SURVEY_MONGO_AUTH_SOURCE', 'XIAOJU_SURVEY_MONGO_AUTH_SOURCE',
)) || ''; )) || 'admin';
const database = await configService.get<string>( const database = await configService.get<string>(
'XIAOJU_SURVEY_MONGO_DB_NAME', 'XIAOJU_SURVEY_MONGO_DB_NAME',
); );
@ -94,7 +94,6 @@ export class AppModule {
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly pluginManager: XiaojuSurveyPluginManager, private readonly pluginManager: XiaojuSurveyPluginManager,
private readonly logger: Logger,
) {} ) {}
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {
consumer.apply(LogRequestMiddleware).forRoutes('*'); consumer.apply(LogRequestMiddleware).forRoutes('*');
@ -108,7 +107,7 @@ export class AppModule {
), ),
new SurveyUtilPlugin(), new SurveyUtilPlugin(),
); );
this.logger.init({ Logger.init({
filename: this.configService.get<string>('XIAOJU_SURVEY_LOGGER_FILENAME'), filename: this.configService.get<string>('XIAOJU_SURVEY_LOGGER_FILENAME'),
}); });
} }

View File

@ -0,0 +1,126 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Authtication } from './authtication';
import { UserService } from '../modules/auth/services/user.service';
import { ConfigService } from '@nestjs/config';
import { AuthtificationException } from '../exceptions/authException';
import { User } from 'src/models/user.entity';
import * as jwt from 'jsonwebtoken';
jest.mock('jsonwebtoken');
describe('Authtication', () => {
let guard: Authtication;
let userService: UserService;
let configService: ConfigService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
Authtication,
{
provide: UserService,
useValue: {
getUserByUsername: jest.fn(),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
guard = module.get<Authtication>(Authtication);
userService = module.get<UserService>(UserService);
configService = module.get<ConfigService>(ConfigService);
});
it('should throw exception if token is not provided', async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({
headers: {},
}),
}),
};
await expect(guard.canActivate(context as any)).rejects.toThrow(
AuthtificationException,
);
});
it('should throw exception if token is invalid', async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
authorization: 'Bearer invalidToken',
},
}),
}),
};
jest.spyOn(jwt, 'verify').mockReturnValue(new Error('token is invalid'));
jest
.spyOn(configService, 'get')
.mockReturnValue('XIAOJU_SURVEY_JWT_SECRET');
await expect(guard.canActivate(context as any)).rejects.toThrow(
AuthtificationException,
);
});
it('should throw exception if user does not exist', async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
authorization: 'Bearer validToken',
},
}),
}),
};
const fakeUser = { username: 'testUser' } as User;
jest.spyOn(jwt, 'verify').mockReturnValue(fakeUser);
jest
.spyOn(configService, 'get')
.mockReturnValue('XIAOJU_SURVEY_JWT_SECRET');
jest.spyOn(userService, 'getUserByUsername').mockResolvedValue(null);
await expect(guard.canActivate(context as any)).rejects.toThrow(
AuthtificationException,
);
});
it('should set user in request object and return true if user exists', async () => {
const request = {
headers: {
authorization: 'Bearer validToken',
},
};
const context = {
switchToHttp: () => ({
getRequest: () => request,
}),
};
const fakeUser = { username: 'testUser' } as User;
jest
.spyOn(configService, 'get')
.mockReturnValue('XIAOJU_SURVEY_JWT_SECRET');
jest.spyOn(userService, 'getUserByUsername').mockResolvedValue(fakeUser);
jest.spyOn(jwt, 'verify').mockReturnValue(fakeUser);
const result = await guard.canActivate(context as any);
expect(result).toBe(true);
expect(request['user']).toEqual(fakeUser);
});
});

View File

@ -39,35 +39,34 @@ export interface DataItem {
showType: boolean; showType: boolean;
showSpliter: boolean; showSpliter: boolean;
type: string; type: string;
valid: string; valid?: string;
field: string; field: string;
title: string; title: string;
placeholder: string; placeholder: string;
randomSort: boolean; randomSort?: boolean;
checked: boolean; checked: boolean;
minNum: string; minNum: string;
maxNum: string; maxNum: string;
star: number; star: number;
nps: NPS; nps?: NPS;
placeholderDesc: string; placeholderDesc: string;
addressType: number; textRange?: TextRange;
isAuto: boolean;
urlKey: string;
textRange: TextRange;
options?: Option[]; options?: Option[];
importKey?: string; importKey?: string;
importData?: string; importData?: string;
cOption?: string; cOption?: string;
cOptions?: string[]; cOptions?: string[];
exclude?: boolean; exclude?: boolean;
rangeConfig?: any;
starStyle?: string;
innerType?: string;
} }
export interface Option { export interface Option {
text: string; text: string;
imageUrl: string;
others: boolean; others: boolean;
mustOthers: boolean; mustOthers?: boolean;
othersKey: string; othersKey?: string;
placeholderDesc: string; placeholderDesc: string;
hash: string; hash: string;
} }
@ -109,10 +108,16 @@ export interface SkinConf {
inputBgColor: string; inputBgColor: string;
} }
export interface BottomConf {
logoImage: string;
logoImageWidth: string;
}
export interface SurveySchemaInterface { export interface SurveySchemaInterface {
bannerConf: BannerConf; bannerConf: BannerConf;
dataConf: DataConf; dataConf: DataConf;
submitConf: SubmitConf; submitConf: SubmitConf;
baseConf: BaseConf; baseConf: BaseConf;
skinConf: SkinConf; skinConf: SkinConf;
bottomConf: BottomConf;
} }

View File

@ -1,13 +1,15 @@
import * as log4js from 'log4js'; import * as log4js from 'log4js';
import moment from 'moment'; import moment from 'moment';
import { REQUEST } from '@nestjs/core';
import { Inject, Request } from '@nestjs/common';
const log4jsLogger = log4js.getLogger(); const log4jsLogger = log4js.getLogger();
export class Logger { export class Logger {
private traceId: string = ''; private static inited = false;
private inited = false;
init(config: { filename: string }) { constructor(@Inject(REQUEST) private req: Request) {}
static init(config: { filename: string }) {
if (this.inited) { if (this.inited) {
return; return;
} }
@ -31,17 +33,15 @@ export class Logger {
}); });
} }
setTraceId(traceId: string) {
this.traceId = traceId;
}
_log(message, options: { dltag?: string; level: string }) { _log(message, options: { dltag?: string; level: string }) {
const datetime = moment().format('YYYY-MM-DD HH:mm:ss.SSS'); const datetime = moment().format('YYYY-MM-DD HH:mm:ss.SSS');
const level = options.level; const level = options.level;
const dltag = options.dltag ? `${options.dltag}||` : ''; const dltag = options.dltag ? `${options.dltag}||` : '';
const traceId = this.traceId ? `traceid=${this.traceId}||` : ''; const traceIdStr = this.req['traceId']
? `traceid=${this.req['traceId']}||`
: '';
return log4jsLogger[level]( return log4jsLogger[level](
`[${datetime}][${level.toUpperCase()}]${dltag}${traceId}${message}`, `[${datetime}][${level.toUpperCase()}]${dltag}${traceIdStr}${message}`,
); );
} }
@ -53,5 +53,3 @@ export class Logger {
return this._log(message, { ...options, level: 'error' }); return this._log(message, { ...options, level: 'error' });
} }
} }
export default new Logger();

View File

@ -1,8 +1,8 @@
import { Provider } from '@nestjs/common'; import { Provider } from '@nestjs/common';
import logger, { Logger } from './index'; import { Logger } from './index';
export const LoggerProvider: Provider = { export const LoggerProvider: Provider = {
provide: Logger, provide: Logger,
useValue: logger, useClass: Logger,
}; };

View File

@ -13,7 +13,7 @@ export class LogRequestMiddleware implements NestMiddleware {
const userAgent = req.get('user-agent') || ''; const userAgent = req.get('user-agent') || '';
const startTime = Date.now(); const startTime = Date.now();
const traceId = genTraceId({ ip }); const traceId = genTraceId({ ip });
this.logger.setTraceId(traceId); req['traceId'] = traceId;
const query = JSON.stringify(req.query); const query = JSON.stringify(req.query);
const body = JSON.stringify(req.body); const body = JSON.stringify(req.body);
this.logger.info( this.logger.info(

View File

@ -0,0 +1,30 @@
import { BaseEntity } from '../base.entity';
import { RECORD_STATUS } from 'src/enums';
describe('BaseEntity', () => {
let baseEntity: BaseEntity;
beforeEach(() => {
baseEntity = new BaseEntity();
});
it('should initialize default info before insert', () => {
const now = Date.now();
baseEntity.initDefaultInfo();
expect(baseEntity.curStatus.status).toBe(RECORD_STATUS.NEW);
expect(baseEntity.curStatus.date).toBeCloseTo(now, -3);
expect(baseEntity.statusList).toHaveLength(1);
expect(baseEntity.statusList[0].status).toBe(RECORD_STATUS.NEW);
expect(baseEntity.statusList[0].date).toBeCloseTo(now, -3);
expect(baseEntity.createDate).toBeCloseTo(now, -3);
expect(baseEntity.updateDate).toBeCloseTo(now, -3);
});
it('should update updateDate before update', () => {
const now = Date.now();
baseEntity.onUpdate();
expect(baseEntity.updateDate).toBeCloseTo(now, -3); // Check if date is close to current time
});
});

View File

@ -0,0 +1,42 @@
import { SurveyResponse } from '../surveyResponse.entity';
import pluginManager from 'src/securityPlugin/pluginManager';
import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin';
import { cloneDeep } from 'lodash';
const mockOriginData = {
data405: '浙江省杭州市西湖区xxx',
data450: '450111000000000000',
data458: '15000000000',
data515: '115019',
data770: '123456@qq.com',
};
describe('SurveyResponse', () => {
beforeEach(() => {
pluginManager.registerPlugin(
new ResponseSecurityPlugin('dataAesEncryptSecretKey'),
);
});
it('should encrypt and decrypt success', async () => {
const surveyResponse = new SurveyResponse();
surveyResponse.data = cloneDeep(mockOriginData);
await surveyResponse.onDataInsert();
expect(surveyResponse.data.data405).not.toBe(mockOriginData.data405);
expect(surveyResponse.data.data450).not.toBe(mockOriginData.data450);
expect(surveyResponse.data.data458).not.toBe(mockOriginData.data458);
expect(surveyResponse.data.data770).not.toBe(mockOriginData.data770);
expect(surveyResponse.secretKeys).toEqual([
'data405',
'data450',
'data458',
'data770',
]);
surveyResponse.onDataLoaded();
expect(surveyResponse.data.data405).toBe(mockOriginData.data405);
expect(surveyResponse.data.data450).toBe(mockOriginData.data450);
expect(surveyResponse.data.data458).toBe(mockOriginData.data458);
expect(surveyResponse.data.data770).toBe(mockOriginData.data770);
});
});

View File

@ -0,0 +1,43 @@
import { Column, ObjectIdColumn, BeforeInsert, BeforeUpdate } from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
export class BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
}

View File

@ -1,16 +1,9 @@
import { import { Entity, Column, Index, ObjectIdColumn } from 'typeorm';
Entity,
Column,
Index,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums'; import { BaseEntity } from './base.entity';
@Entity({ name: 'captcha' }) @Entity({ name: 'captcha' })
export class Captcha { export class Captcha extends BaseEntity {
@Index({ @Index({
expireAfterSeconds: expireAfterSeconds:
new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000, new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000,
@ -18,41 +11,6 @@ export class Captcha {
@ObjectIdColumn() @ObjectIdColumn()
_id: ObjectId; _id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@Column() @Column()
text: string; text: string;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
} }

View File

@ -1,17 +1,10 @@
import { import { Entity, Column, Index, ObjectIdColumn } from 'typeorm';
Entity,
Column,
Index,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import { ENCRYPT_TYPE } from '../enums/encrypt'; import { ENCRYPT_TYPE } from '../enums/encrypt';
import { BaseEntity } from './base.entity';
@Entity({ name: 'clientEncrypt' }) @Entity({ name: 'clientEncrypt' })
export class ClientEncrypt { export class ClientEncrypt extends BaseEntity {
@Index({ @Index({
expireAfterSeconds: expireAfterSeconds:
new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000, new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000,
@ -19,18 +12,6 @@ export class ClientEncrypt {
@ObjectIdColumn() @ObjectIdColumn()
_id: ObjectId; _id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column('jsonb') @Column('jsonb')
data: { data: {
secretKey?: string; // aes加密的密钥 secretKey?: string; // aes加密的密钥
@ -40,27 +21,4 @@ export class ClientEncrypt {
@Column() @Column()
type: ENCRYPT_TYPE; type: ENCRYPT_TYPE;
@Column()
createDate: number;
@Column()
updateDate: number;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
} }

View File

@ -1,36 +1,8 @@
import { import { Entity, Column } from 'typeorm';
Entity, import { BaseEntity } from './base.entity';
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
@Entity({ name: 'counter' }) @Entity({ name: 'counter' })
export class Counter { export class Counter extends BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@Column() @Column()
key: string; key: string;
@ -42,21 +14,4 @@ export class Counter {
@Column('jsonb') @Column('jsonb')
data: Record<string, any>; data: Record<string, any>;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
} }

View File

@ -1,37 +1,9 @@
import { import { Entity, Column } from 'typeorm';
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import { SurveySchemaInterface } from '../interfaces/survey'; import { SurveySchemaInterface } from '../interfaces/survey';
import { BaseEntity } from './base.entity';
@Entity({ name: 'surveyPublish' }) @Entity({ name: 'surveyPublish' })
export class ResponseSchema { export class ResponseSchema extends BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@Column() @Column()
title: string; title: string;
@ -43,21 +15,4 @@ export class ResponseSchema {
@Column() @Column()
pageId: string; pageId: string;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
} }

View File

@ -1,57 +1,11 @@
import { import { Entity, Column } from 'typeorm';
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import { SurveySchemaInterface } from '../interfaces/survey'; import { SurveySchemaInterface } from '../interfaces/survey';
import { BaseEntity } from './base.entity';
@Entity({ name: 'surveyConf' }) @Entity({ name: 'surveyConf' })
export class SurveyConf { export class SurveyConf extends BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column({ type: 'bigint' })
createDate: number;
@Column({ type: 'bigint' })
updateDate: number;
@Column('jsonb') @Column('jsonb')
code: SurveySchemaInterface; code: SurveySchemaInterface;
@Column() @Column()
pageId: string; pageId: string;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
} }

View File

@ -1,37 +1,10 @@
import { import { Entity, Column } from 'typeorm';
Entity, import { HISTORY_TYPE } from '../enums';
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { HISTORY_TYPE, RECORD_STATUS } from '../enums';
import { SurveySchemaInterface } from '../interfaces/survey'; import { SurveySchemaInterface } from '../interfaces/survey';
import { BaseEntity } from './base.entity';
@Entity({ name: 'surveyHistory' }) @Entity({ name: 'surveyHistory' })
export class SurveyHistory { export class SurveyHistory extends BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@Column() @Column()
pageId: string; pageId: string;
@ -46,21 +19,4 @@ export class SurveyHistory {
username: string; username: string;
_id: string; _id: string;
}; };
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
} }

View File

@ -1,36 +1,8 @@
import { import { Entity, Column } from 'typeorm';
Entity, import { BaseEntity } from './base.entity';
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
@Entity({ name: 'surveyMeta' }) @Entity({ name: 'surveyMeta' })
export class SurveyMeta { export class SurveyMeta extends BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@Column() @Column()
title: string; title: string;
@ -54,21 +26,4 @@ export class SurveyMeta {
@Column() @Column()
createFrom: string; createFrom: string;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
} }

View File

@ -1,20 +1,9 @@
import { import { Entity, Column, BeforeInsert, AfterLoad } from 'typeorm';
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
AfterLoad,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import pluginManager from '../securityPlugin/pluginManager'; import pluginManager from '../securityPlugin/pluginManager';
import { BaseEntity } from './base.entity';
@Entity({ name: 'surveySubmit' }) @Entity({ name: 'surveySubmit' })
export class SurveyResponse { export class SurveyResponse extends BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column() @Column()
pageId: string; pageId: string;
@ -36,44 +25,13 @@ export class SurveyResponse {
@Column('jsonb') @Column('jsonb')
optionTextAndId: Record<string, any>; optionTextAndId: Record<string, any>;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@BeforeInsert() @BeforeInsert()
initDefaultInfo() { async onDataInsert() {
const now = Date.now(); return await pluginManager.triggerHook('beforeResponseDataCreate', this);
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
pluginManager.triggerHook('beforeResponseDataCreate', this);
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
} }
@AfterLoad() @AfterLoad()
onDataLoaded() { async onDataLoaded() {
pluginManager.triggerHook('afterResponseDataReaded', this); return await pluginManager.triggerHook('afterResponseDataReaded', this);
} }
} }

View File

@ -1,56 +1,10 @@
import { import { Entity, Column } from 'typeorm';
Entity, import { BaseEntity } from './base.entity';
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
@Entity({ name: 'user' }) @Entity({ name: 'user' })
export class User { export class User extends BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@Column() @Column()
username: string; username: string;
@Column() @Column()
password: string; password: string;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
} }

View File

@ -1,56 +1,10 @@
import { import { Entity, Column } from 'typeorm';
Entity, import { BaseEntity } from './base.entity';
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
@Entity({ name: 'word' }) @Entity({ name: 'word' })
export class Word { export class Word extends BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column() @Column()
text: string; text: string;
@Column() @Column()
type: string; type: string;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
} }

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller'; import { AuthController } from '../controllers/auth.controller';
import { UserService } from '../services/user.service'; import { UserService } from '../services/user.service';
import { CaptchaService } from '../services/captcha.service'; import { CaptchaService } from '../services/captcha.service';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
@ -10,6 +10,7 @@ import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { User } from 'src/models/user.entity'; import { User } from 'src/models/user.entity';
import { Captcha } from 'src/models/captcha.entity';
jest.mock('../services/captcha.service'); jest.mock('../services/captcha.service');
jest.mock('../services/auth.service'); jest.mock('../services/auth.service');
@ -23,7 +24,7 @@ describe('AuthController', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot()], // imports: [ConfigModule.forRoot()],
controllers: [AuthController], controllers: [AuthController],
providers: [UserService, CaptchaService, ConfigService, AuthService], providers: [UserService, CaptchaService, ConfigService, AuthService],
}).compile(); }).compile();
@ -149,4 +150,19 @@ describe('AuthController', () => {
); );
}); });
}); });
describe('getCaptcha', () => {
it('should return captcha image and id', async () => {
const captcha = new Captcha();
const mockCaptchaId = new ObjectId();
captcha._id = mockCaptchaId;
jest.spyOn(captchaService, 'createCaptcha').mockResolvedValue(captcha);
const result = await controller.getCaptcha();
expect(result.code).toBe(200);
expect(result.data.id).toBe(mockCaptchaId.toString());
expect(typeof result.data.img).toBe('string');
});
});
}); });

View File

@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service'; import { AuthService } from '../services/auth.service';
import { sign } from 'jsonwebtoken'; import { sign } from 'jsonwebtoken';
jest.mock('jsonwebtoken'); jest.mock('jsonwebtoken');

View File

@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { CaptchaService } from './captcha.service'; import { CaptchaService } from '../services/captcha.service';
import { MongoRepository } from 'typeorm'; import { MongoRepository } from 'typeorm';
import { Captcha } from 'src/models/captcha.entity'; import { Captcha } from 'src/models/captcha.entity';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';

View File

@ -0,0 +1,142 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { UserService } from '../services/user.service';
import { User } from 'src/models/user.entity';
import { HttpException } from 'src/exceptions/httpException';
import { hash256 } from 'src/utils/hash256';
describe('UserService', () => {
let service: UserService;
let userRepository: MongoRepository<User>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: getRepositoryToken(User),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
},
},
],
}).compile();
service = module.get<UserService>(UserService);
userRepository = module.get<MongoRepository<User>>(
getRepositoryToken(User),
);
});
it('should create a user', async () => {
const userInfo = {
username: 'testUser',
password: 'testPassword',
} as User;
const createSpy = jest
.spyOn(userRepository, 'create')
.mockImplementation(() => userInfo);
const saveSpy = jest
.spyOn(userRepository, 'save')
.mockResolvedValue(userInfo);
const findOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(null);
const user = await service.createUser(userInfo);
expect(findOneSpy).toHaveBeenCalledWith({
where: { username: userInfo.username },
});
expect(createSpy).toHaveBeenCalledWith({
username: userInfo.username,
password: expect.any(String),
});
expect(saveSpy).toHaveBeenCalled();
expect(user).toEqual(userInfo);
});
it('should throw when trying to create an existing user', async () => {
const userInfo = {
username: 'existingUser',
password: 'existingPassword',
} as User;
const findOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(userInfo);
await expect(service.createUser(userInfo)).rejects.toThrow(HttpException);
expect(findOneSpy).toHaveBeenCalledWith({
where: { username: userInfo.username },
});
});
it('should return a user by credentials', async () => {
const userInfo = {
username: 'existingUser',
password: 'existingPassword',
};
const hashedPassword = hash256(userInfo.password);
jest.spyOn(userRepository, 'findOne').mockImplementation(() => {
return Promise.resolve({
username: userInfo.username,
password: hashedPassword,
} as User);
});
const user = await service.getUser(userInfo);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: {
username: userInfo.username,
password: hashedPassword,
},
});
expect(user).toEqual({ ...userInfo, password: hashedPassword });
});
it('should return undefined when user is not found by credentials', async () => {
const userInfo = {
username: 'nonExistingUser',
password: 'nonExistingPassword',
};
const hashedPassword = hash256(userInfo.password);
const findOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(null);
const user = await service.getUser(userInfo);
expect(findOneSpy).toHaveBeenCalledWith({
where: {
username: userInfo.username,
password: hashedPassword,
},
});
expect(user).toBe(null);
});
it('should return a user by username', async () => {
const username = 'existingUser';
const userInfo = {
username: username,
password: 'existingPassword',
} as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo);
const user = await service.getUserByUsername(username);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { username: username },
});
expect(user).toEqual(userInfo);
});
});

View File

@ -2,9 +2,9 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm'; import { MongoRepository } from 'typeorm';
import { User } from 'src/models/user.entity'; import { User } from 'src/models/user.entity';
import { createHash } from 'crypto';
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 { hash256 } from 'src/utils/hash256';
@Injectable() @Injectable()
export class UserService { export class UserService {
@ -13,10 +13,6 @@ export class UserService {
private readonly userRepository: MongoRepository<User>, private readonly userRepository: MongoRepository<User>,
) {} ) {}
private hash256(text) {
return createHash('sha256').update(text).digest('hex');
}
async createUser(userInfo: { async createUser(userInfo: {
username: string; username: string;
password: string; password: string;
@ -31,7 +27,7 @@ export class UserService {
const newUser = this.userRepository.create({ const newUser = this.userRepository.create({
username: userInfo.username, username: userInfo.username,
password: this.hash256(userInfo.password), password: hash256(userInfo.password),
}); });
return this.userRepository.save(newUser); return this.userRepository.save(newUser);
@ -44,7 +40,7 @@ export class UserService {
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { where: {
username: userInfo.username, username: userInfo.username,
password: this.hash256(userInfo.password), // Please handle password hashing here password: hash256(userInfo.password), // Please handle password hashing here
}, },
}); });

View File

@ -0,0 +1,158 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DataStatisticController } from '../controllers/dataStatistic.controller';
import { DataStatisticService } from '../services/dataStatistic.service';
import { SurveyMetaService } from '../services/surveyMeta.service';
import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service';
import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider';
import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager';
import { Authtication } from 'src/guards/authtication';
import { UserService } from 'src/modules/auth/services/user.service';
import { ConfigService } from '@nestjs/config';
import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin';
import { ObjectId } from 'mongodb';
jest.mock('../services/dataStatistic.service');
jest.mock('../services/surveyMeta.service');
jest.mock('../../surveyResponse/services/responseScheme.service');
describe('DataStatisticController', () => {
let controller: DataStatisticController;
let dataStatisticService: DataStatisticService;
let surveyMetaService: SurveyMetaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DataStatisticController],
providers: [
DataStatisticService,
SurveyMetaService,
ResponseSchemaService,
PluginManagerProvider,
ConfigService,
{
provide: Authtication,
useClass: jest.fn().mockImplementation(() => ({
canActivate: () => true,
})),
},
{
provide: UserService,
useClass: jest.fn().mockImplementation(() => ({
getUserByUsername() {
return {};
},
})),
},
],
}).compile();
controller = module.get<DataStatisticController>(DataStatisticController);
dataStatisticService =
module.get<DataStatisticService>(DataStatisticService);
surveyMetaService = module.get<SurveyMetaService>(SurveyMetaService);
const pluginManager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager,
);
pluginManager.registerPlugin(
new ResponseSecurityPlugin('dataAesEncryptSecretKey'),
);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('data', () => {
it('should return data table', async () => {
const surveyId = new ObjectId().toString();
const mockRequest = {
query: {
surveyId,
},
user: {
username: 'testUser',
},
};
const mockDataTable = {
total: 10,
listHead: [
{
field: 'xxx',
title: 'xxx',
type: 'xxx',
othersCode: 'xxx',
},
],
listBody: [
{ difTime: '0.5', createDate: '2024-02-11' },
{ difTime: '0.5', createDate: '2024-02-11' },
],
};
jest
.spyOn(surveyMetaService, 'checkSurveyAccess')
.mockResolvedValueOnce(undefined);
jest
.spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId')
.mockResolvedValueOnce({} as any);
jest
.spyOn(dataStatisticService, 'getDataTable')
.mockResolvedValueOnce(mockDataTable);
const result = await controller.data(mockRequest.query, mockRequest);
expect(result).toEqual({
code: 200,
data: mockDataTable,
});
});
it('should return data table with isDesensitive', async () => {
const surveyId = new ObjectId().toString();
const mockRequest = {
query: {
surveyId,
isDesensitive: true,
},
user: {
username: 'testUser',
},
};
const mockDataTable = {
total: 10,
listHead: [
{
field: 'xxx',
title: 'xxx',
type: 'xxx',
othersCode: 'xxx',
},
],
listBody: [
{ difTime: '0.5', createDate: '2024-02-11', data123: '15200000000' },
{ difTime: '0.5', createDate: '2024-02-11', data123: '13800000000' },
],
};
jest
.spyOn(surveyMetaService, 'checkSurveyAccess')
.mockResolvedValueOnce(undefined);
jest
.spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId')
.mockResolvedValueOnce({} as any);
jest
.spyOn(dataStatisticService, 'getDataTable')
.mockResolvedValueOnce(mockDataTable);
const result = await controller.data(mockRequest.query, mockRequest);
expect(result).toEqual({
code: 200,
data: mockDataTable,
});
});
});
});

View File

@ -0,0 +1,313 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DataStatisticService } from '../services/dataStatistic.service';
import { MongoRepository } from 'typeorm';
import { SurveyResponse } from 'src/models/surveyResponse.entity';
import {
mockResponseSchema,
mockSensitiveResponseSchema,
} from './mockResponseSchema';
import { ObjectId } from 'mongodb';
import { cloneDeep } from 'lodash';
import { getRepositoryToken } from '@nestjs/typeorm';
import { RECORD_STATUS } from 'src/enums';
import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider';
import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager';
import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin';
describe('DataStatisticService', () => {
let service: DataStatisticService;
let surveyResponseRepository: MongoRepository<SurveyResponse>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DataStatisticService,
{
provide: getRepositoryToken(SurveyResponse),
useClass: MongoRepository,
},
PluginManagerProvider,
],
}).compile();
service = module.get<DataStatisticService>(DataStatisticService);
surveyResponseRepository = module.get<MongoRepository<SurveyResponse>>(
getRepositoryToken(SurveyResponse),
);
const manager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager,
);
manager.registerPlugin(
new ResponseSecurityPlugin('dataAesEncryptSecretKey'),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getDataTable', () => {
it('should return correct table data', async () => {
const surveyId = '65afc62904d5db18534c0f78';
const pageNum = 1;
const pageSize = 10;
const responseSchema = mockResponseSchema;
const surveyResponseList = [
{
_id: new ObjectId('65f1baff92862d6a9067ad0c'),
pageId: '65afc62904d5db18534c0f78',
surveyPath: 'JgMLGInV',
data: {
data458: '111',
data549: '222',
data515_115019: '333',
data515: '115019',
data997_211974: '444',
data997: ['211974', '842501'],
data517: '917392',
data413_3: '555',
data413: 3,
data863: '109239',
},
difTime: 21278,
clientTime: 1710340862733.0,
secretKeys: [],
optionTextAndId: {
data549: [
{
hash: '273008',
text: '选项1',
},
{
hash: '160703',
text: '选项2',
},
],
data515: [
{
hash: '115019',
text: '<p>选项1</p>',
},
{
hash: '115020',
text: '<p>选项2</p>',
},
{
hash: '119074',
text: '<p>选项</p>',
},
],
data997: [
{
hash: '211974',
text: '<p>选项1</p>',
},
{
hash: '842501',
text: '<p>选项2</p>',
},
{
hash: '650873',
text: '<p>选项</p>',
},
],
data517: [
{
hash: '917392',
text: '对',
},
{
hash: '156728',
text: '错',
},
],
data413: [
{
hash: '502734',
text: '选项1',
},
{
hash: '278946',
text: '选项2',
},
],
data863: [
{
hash: '109239',
text: '<p>选项1</p>',
},
{
hash: '899262',
text: '<p>选项2</p>',
},
],
},
curStatus: {
status: RECORD_STATUS.NEW,
date: 1710340863123.0,
},
statusList: [
{
status: RECORD_STATUS.NEW,
date: 1710340863123.0,
},
],
createDate: 1710340863123.0,
updateDate: 1710340863123.0,
},
] as unknown as Array<SurveyResponse>;
jest
.spyOn(surveyResponseRepository, 'findAndCount')
.mockResolvedValue([surveyResponseList, surveyResponseList.length]);
const result = await service.getDataTable({
surveyId,
pageNum,
pageSize,
responseSchema,
});
expect(result).toEqual({
total: 1,
listHead: expect.arrayContaining([
expect.objectContaining({
field: expect.any(String),
title: expect.any(String),
type: expect.stringMatching(
/^(text|textarea|radio|checkbox|binary-choice|radio-star|vote)$/,
),
othersCode: expect.arrayContaining([
expect.objectContaining({
code: expect.any(String),
option: expect.any(String),
}),
]),
}),
]),
listBody: expect.arrayContaining([
expect.objectContaining({
data458: expect.any(String),
data549: expect.any(String),
data515_115019: expect.any(String),
data515: expect.any(String),
data997_211974: expect.any(String),
data997: expect.any(String),
data517: expect.any(String),
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),
}),
]),
});
});
it('should return desensitive table data', async () => {
const mockSchema = cloneDeep(mockSensitiveResponseSchema);
const surveyResponseList: Array<SurveyResponse> = [
{
_id: new ObjectId('65f2a2e892862d6a9067ad29'),
pageId: '65f29f3192862d6a9067ad1c',
surveyPath: 'EBzdmnSp',
data: {
data458: 'U2FsdGVkX18IlyS9gSKNTAG0llOVQmrGUzRn/r95VKw=',
data515: '115019',
data450:
'U2FsdGVkX1+ArNkHhqSmHrCWWT2oxTGBlyTcXdJfQTwqBouROeITBx/aAp7pjKk4',
data405:
'U2FsdGVkX19bRmf3uEmXAJ/6zXd1Znr3cZsD5v4Nocr2v5XG1taXluz8cohFkDyH',
data770: 'U2FsdGVkX18ldQMhJjFXO8aerjftZLpFnRQ4/FVcCLI=',
},
difTime: 806707,
clientTime: 1710400229573.0,
secretKeys: ['data458', 'data450', 'data405', 'data770'],
optionTextAndId: {
data515: [
{
hash: '115019',
text: '<p>男</p>',
},
{
hash: '115020',
text: '<p>女</p>',
},
],
data450: [
{
hash: '979954',
text: '选项1',
},
{
hash: '083007',
text: '选项2',
},
],
data405: [
{
hash: '443109',
text: '选项1',
},
{
hash: '871142',
text: '选项2',
},
],
data770: [
{
hash: '051056',
text: '选项1',
},
{
hash: '835356',
text: '选项2',
},
],
},
curStatus: {
status: RECORD_STATUS.NEW,
date: 1710400232161.0,
},
statusList: [
{
status: RECORD_STATUS.NEW,
date: 1710400232161.0,
},
],
createDate: 1710400232161.0,
updateDate: 1710400232161.0,
},
] as unknown as Array<SurveyResponse>;
const surveyId = mockSchema.pageId;
const pageNum = 1;
const pageSize = 10;
jest
.spyOn(surveyResponseRepository, 'findAndCount')
.mockResolvedValue([surveyResponseList, surveyResponseList.length]);
const result = await service.getDataTable({
surveyId,
pageNum,
pageSize,
responseSchema: mockSchema,
});
expect(result.listBody).toEqual(
expect.arrayContaining([
expect.objectContaining({
createDate: expect.any(String),
data405: expect.any(String),
data450: expect.any(String),
data458: expect.any(String),
data515: expect.any(String),
data770: expect.any(String),
difTime: expect.any(String),
}),
]),
);
});
});
});

View File

@ -0,0 +1,637 @@
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { RECORD_STATUS } from 'src/enums';
import { ObjectId } from 'mongodb';
export const mockSensitiveResponseSchema: ResponseSchema = {
_id: new ObjectId('65f29f8892862d6a9067ad25'),
curStatus: {
status: RECORD_STATUS.PUBLISHED,
date: 1710399368439,
},
statusList: [
{
status: RECORD_STATUS.PUBLISHED,
date: 1710399368439,
},
],
createDate: 1710399368440,
updateDate: 1710399368440,
title: '加密全流程',
surveyPath: 'EBzdmnSp',
code: {
bannerConf: {
titleConfig: {
mainTitle:
'<h3 style="text-align: center">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style="color: rgb(204, 0, 0)">期待您的参与!</span></p>',
subTitle: '',
},
bannerConfig: {
bgImage: '/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp',
videoLink: '',
postImg: '',
},
},
baseConf: {
begTime: '2024-03-14 14:54:41',
endTime: '2034-03-14 14:54:41',
language: 'chinese',
tLimit: 0,
answerBegTime: '',
answerEndTime: '',
},
bottomConf: {
logoImage: '/imgs/Logo.webp',
logoImageWidth: '60%',
},
skinConf: {
skinColor: '#4a4c5b',
inputBgColor: '#ffffff',
},
submitConf: {
submitTitle: '提交',
msgContent: {
msg_200: '提交成功',
msg_9001: '您来晚了,感谢支持问卷~',
msg_9002: '请勿多次提交!',
msg_9003: '您来晚了,已经满额!',
msg_9004: '提交失败!',
},
confirmAgain: {
is_again: true,
again_text: '确认要提交吗?',
},
},
dataConf: {
dataList: [
{
isRequired: true,
showIndex: true,
showType: true,
showSpliter: true,
type: 'text',
valid: '',
field: 'data458',
title: '<p>您的手机号</p>',
placeholder: '',
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
star: 5,
nps: {
leftText: '极不满意',
rightText: '极满意',
},
placeholderDesc: '',
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
isRequired: true,
showIndex: true,
showType: true,
showSpliter: true,
type: 'radio',
placeholderDesc: '',
field: 'data515',
title: '<p>您的性别</p>',
placeholder: '',
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
options: [
{
text: '<p>男</p>',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
hash: '115019',
},
{
text: '<p>女</p>',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
hash: '115020',
},
],
importKey: 'single',
importData: '',
cOption: '',
cOptions: [],
nps: {
leftText: '极不满意',
rightText: '极满意',
},
star: 5,
exclude: false,
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
field: 'data450',
showIndex: true,
showType: true,
showSpliter: true,
type: 'text',
placeholderDesc: '',
title: '<p>身份证</p>',
placeholder: '',
valid: '',
isRequired: true,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {},
options: [
{
text: '选项1',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '979954',
},
{
text: '选项2',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '083007',
},
],
star: 5,
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
field: 'data405',
showIndex: true,
showType: true,
showSpliter: true,
type: 'text',
placeholderDesc: '',
title: '<p>地址</p>',
placeholder: '',
valid: '',
isRequired: true,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {},
options: [
{
text: '选项1',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '443109',
},
{
text: '选项2',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '871142',
},
],
star: 5,
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
field: 'data770',
showIndex: true,
showType: true,
showSpliter: true,
type: 'text',
placeholderDesc: '',
title: '<p>邮箱</p>',
placeholder: '',
valid: '',
isRequired: true,
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {},
options: [
{
text: '选项1',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '051056',
},
{
text: '选项2',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '835356',
},
],
star: 5,
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
],
},
},
pageId: '65f29f3192862d6a9067ad1c',
} as ResponseSchema;
export const mockResponseSchema: ResponseSchema = {
_id: new ObjectId('65b0d46e04d5db18534c0f7c'),
curStatus: {
status: RECORD_STATUS.PUBLISHED,
date: 1710340841287.0,
},
statusList: [
{
status: RECORD_STATUS.PUBLISHED,
date: 1706018345927.0,
},
],
title: '新系统创建的',
surveyPath: 'JgMLGInV',
code: {
bannerConf: {
titleConfig: {
mainTitle:
'<h3 style="text-align: center">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style="color: rgb(204, 0, 0)">期待您的参与!</span></p>',
subTitle: '',
},
bannerConfig: {
bgImage:
'http://10.190.55.101:3000/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp',
videoLink: '',
postImg: '',
},
},
baseConf: {
begTime: '2024-01-23 21:59:05',
endTime: '2034-01-23 21:59:05',
language: 'chinese',
tLimit: 0,
answerBegTime: '',
answerEndTime: '',
},
bottomConf: {
logoImage: '/imgs/Logo.webp',
logoImageWidth: '60%',
},
skinConf: {
skinColor: '#4a4c5b',
inputBgColor: '#ffffff',
},
submitConf: {
submitTitle: '提交',
msgContent: {
msg_200: '<p>提交成功</p>',
msg_9001: '您来晚了,感谢支持问卷~',
msg_9002: '请勿多次提交!',
msg_9003: '您来晚了,已经满额!',
msg_9004: '提交失败!',
},
confirmAgain: {
is_again: true,
again_text: '确认要提交吗?',
},
},
dataConf: {
dataList: [
{
isRequired: true,
showIndex: true,
showType: true,
showSpliter: true,
type: 'text',
valid: '',
field: 'data458',
title: '标题1',
placeholder: '',
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
star: 5,
nps: {
leftText: '极不满意',
rightText: '极满意',
},
placeholderDesc: '',
},
{
field: 'data549',
showIndex: true,
showType: true,
showSpliter: true,
type: 'textarea',
placeholderDesc: '',
title: '标题2',
placeholder: '',
valid: '',
isRequired: true,
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {},
options: [
{
text: '选项1',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '273008',
},
{
text: '选项2',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '160703',
},
],
star: 5,
},
{
isRequired: true,
showIndex: true,
showType: true,
showSpliter: true,
type: 'radio',
placeholderDesc: '',
field: 'data515',
title: '标题2',
placeholder: '',
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
options: [
{
text: '<p>选项1</p>',
others: true,
mustOthers: false,
othersKey: 'data515_115019',
placeholderDesc: '',
hash: '115019',
},
{
text: '<p>选项2</p>',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
hash: '115020',
},
{
text: '<p>选项</p>',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
hash: '119074',
},
],
importKey: 'single',
importData: '',
cOption: '',
cOptions: [],
nps: {
leftText: '极不满意',
rightText: '极满意',
},
star: 5,
},
{
field: 'data997',
showIndex: true,
showType: true,
showSpliter: true,
type: 'checkbox',
placeholderDesc: '',
title: '标题4',
placeholder: '',
valid: '',
isRequired: true,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {},
options: [
{
text: '<p>选项1</p>',
others: true,
othersKey: 'data997_211974',
placeholderDesc: '',
hash: '211974',
},
{
text: '<p>选项2</p>',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '842501',
},
{
text: '<p>选项</p>',
others: false,
othersKey: 'data997_211974',
placeholderDesc: '',
hash: '650873',
},
],
star: 5,
},
{
field: 'data517',
showIndex: true,
showType: true,
showSpliter: true,
type: 'binary-choice',
placeholderDesc: '',
title: '标题5',
placeholder: '',
valid: '',
isRequired: true,
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {},
options: [
{
text: '对',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '917392',
},
{
text: '错',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '156728',
},
],
star: 5,
},
{
field: 'data413',
showIndex: true,
showType: true,
showSpliter: true,
type: 'radio-star',
placeholderDesc: '',
title: '标题6',
placeholder: '',
valid: '',
isRequired: true,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {
'1': {
isShowInput: true,
text: '',
required: false,
explain: '',
},
'2': {
isShowInput: true,
text: '',
required: false,
explain: '',
},
'3': {
isShowInput: true,
text: '',
required: false,
explain: '',
},
'4': {
isShowInput: false,
text: '',
required: false,
explain: '',
},
'5': {
isShowInput: false,
text: '',
required: false,
explain: '',
},
},
options: [
{
text: '选项1',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '502734',
},
{
text: '选项2',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '278946',
},
],
star: 5,
},
{
field: 'data863',
showIndex: true,
showType: true,
showSpliter: true,
type: 'vote',
placeholderDesc: '',
title: '标题7',
placeholder: '',
valid: '',
isRequired: true,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {},
options: [
{
text: '<p>选项1</p>',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '109239',
},
{
text: '<p>选项2</p>',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '899262',
},
],
star: 5,
innerType: 'radio',
},
],
},
},
pageId: '65afc62904d5db18534c0f78',
createDate: 1710340841289,
updateDate: 1710340841289.0,
} as ResponseSchema;

View File

@ -8,6 +8,8 @@ import { SurveyHistoryService } from '../services/surveyHistory.service';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { SurveyConf } from 'src/models/surveyConf.entity'; import { SurveyConf } from 'src/models/surveyConf.entity';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
// Mock the services // Mock the services
jest.mock('../services/surveyMeta.service'); jest.mock('../services/surveyMeta.service');
@ -186,8 +188,6 @@ describe('SurveyController', () => {
code: 200, code: 200,
}); });
}); });
// Add more test cases for different scenarios
}); });
describe('deleteSurvey', () => { describe('deleteSurvey', () => {
@ -218,8 +218,6 @@ describe('SurveyController', () => {
code: 200, code: 200,
}); });
}); });
// Add more test cases for different scenarios
}); });
describe('getSurvey', () => { describe('getSurvey', () => {
@ -255,7 +253,7 @@ describe('SurveyController', () => {
}); });
describe('publishSurvey', () => { describe('publishSurvey', () => {
it('should publish a survey and its response schema', async () => { it('should publish a survey success', async () => {
const surveyId = new ObjectId(); const surveyId = new ObjectId();
const surveyMeta = { const surveyMeta = {
_id: surveyId, _id: surveyId,
@ -304,5 +302,49 @@ describe('SurveyController', () => {
code: 200, code: 200,
}); });
}); });
it('should not publish a survey with forbidden content', async () => {
const surveyId = new ObjectId();
const surveyMeta = {
_id: surveyId,
surveyType: 'normal',
owner: 'testUser',
} as SurveyMeta;
jest
.spyOn(surveyMetaService, 'checkSurveyAccess')
.mockResolvedValue(Promise.resolve(surveyMeta));
jest
.spyOn(surveyConfService, 'getSurveyConfBySurveyId')
.mockResolvedValue(
Promise.resolve({
_id: new ObjectId(),
pageId: surveyId.toString(),
} as SurveyConf),
);
jest
.spyOn(surveyConfService, 'getSurveyContentByCode')
.mockResolvedValue({
text: '违禁词',
});
jest
.spyOn(contentSecurityService, 'isForbiddenContent')
.mockResolvedValue(true);
await expect(
controller.publishSurvey(
{ surveyId: surveyId.toString() },
{ user: { username: 'testUser', _id: 'testUserId' } },
),
).rejects.toThrow(
new HttpException(
'问卷存在非法关键字,不允许发布',
EXCEPTION_CODE.SURVEY_CONTENT_NOT_ALLOW,
),
);
});
}); });
}); });

View File

@ -0,0 +1,152 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MongoRepository } from 'typeorm';
import { SurveyConf } from 'src/models/surveyConf.entity';
import { SurveyConfService } from '../services/surveyConf.service';
import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';
import { getRepositoryToken } from '@nestjs/typeorm';
import { SurveySchemaInterface } from 'src/interfaces/survey';
describe('SurveyConfService', () => {
let service: SurveyConfService;
let surveyConfRepository: MongoRepository<SurveyConf>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SurveyConfService,
{
provide: getRepositoryToken(SurveyConf),
useValue: {
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(),
},
},
],
}).compile();
service = module.get<SurveyConfService>(SurveyConfService);
surveyConfRepository = module.get<MongoRepository<SurveyConf>>(
getRepositoryToken(SurveyConf),
);
});
it('should create survey configuration successfully', async () => {
const mockSchemaData = {};
jest.mock('../utils', () => ({
getSchemaBySurveyType: jest.fn().mockResolvedValue(mockSchemaData),
}));
surveyConfRepository.create = jest
.fn()
.mockReturnValue({ pageId: 'testId', code: mockSchemaData });
surveyConfRepository.save = jest
.fn()
.mockResolvedValue({ id: 1, pageId: 'testId', code: mockSchemaData });
const result = await service.createSurveyConf({
surveyId: 'testId',
surveyType: 'normal',
createMethod: '',
createFrom: '',
});
expect(result).toEqual({ id: 1, pageId: 'testId', code: mockSchemaData });
expect(surveyConfRepository.findOne).not.toHaveBeenCalled();
expect(surveyConfRepository.create).toHaveBeenCalledTimes(1);
expect(surveyConfRepository.save).toHaveBeenCalledTimes(1);
});
it('should throw SurveyNotFoundException when survey config not found', async () => {
try {
await service.getSurveyConfBySurveyId('nonExistingId');
} catch (error) {
expect(error).toBeInstanceOf(SurveyNotFoundException);
expect(error.message).toBe('问卷配置不存在');
}
expect(surveyConfRepository.findOne).toHaveBeenCalledWith({
where: { pageId: 'nonExistingId' },
});
});
it('should save survey configuration', async () => {
// 准备参数和模拟数据
const surveyId = 'someSurveyId';
const schema = {
dataConf: {
dataList: [],
},
bannerConf: {},
submitConf: {},
baseConf: {},
skinConf: {},
} as SurveySchemaInterface;
jest.spyOn(surveyConfRepository, 'findOne').mockResolvedValue({
surveyId: surveyId,
code: schema,
} as unknown as SurveyConf);
// 调用待测试的方法
await service.saveSurveyConf({ surveyId, schema });
// 验证save方法被调用了一次并且传入了正确的参数
expect(surveyConfRepository.save).toHaveBeenCalledTimes(1);
expect(surveyConfRepository.save).toHaveBeenCalledWith({
surveyId: surveyId,
code: schema,
});
});
it('should throw when saving survey configuration with non-existing surveyId', async () => {
// 准备参数
const surveyId = 'nonExistingSurveyId';
const schema = {
dataConf: {
dataList: [],
},
bannerConf: {},
submitConf: {},
baseConf: {},
skinConf: {},
} as SurveySchemaInterface;
// 调用待测试的方法并期待抛出异常
await expect(service.saveSurveyConf({ surveyId, schema })).rejects.toThrow(
SurveyNotFoundException,
);
// 验证save方法没有被调用因为没有找到对应的surveyId
expect(surveyConfRepository.save).not.toHaveBeenCalled();
});
// getSurveyContentByCode方法的单元测试
it('should get survey content by code', async () => {
// 准备参数和模拟数据
const schema = {
dataConf: {
dataList: [
{
title: 'Title1',
options: [{ text: 'Option1' }, { text: 'Option2' }],
},
{
title: 'Title2',
},
],
},
bannerConf: {},
submitConf: {},
baseConf: {},
skinConf: {},
} as SurveySchemaInterface;
// 调用待测试的方法
const result = await service.getSurveyContentByCode(schema);
// 验证返回结果是否正确
expect(result).toEqual({
text: 'Title1\nOption1\nOption2\nTitle2',
});
});
});

View File

@ -0,0 +1,74 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SurveyHistoryController } from '../controllers/surveyHistory.controller';
import { SurveyHistoryService } from '../services/surveyHistory.service';
import { SurveyMetaService } from '../services/surveyMeta.service';
import { UserService } from 'src/modules/auth/services/user.service';
import { Authtication } from 'src/guards/authtication';
import { ConfigService } from '@nestjs/config';
describe('SurveyHistoryController', () => {
let controller: SurveyHistoryController;
let surveyHistoryService: SurveyHistoryService;
let surveyMetaService: SurveyMetaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SurveyHistoryController],
providers: [
ConfigService,
{
provide: SurveyHistoryService,
useClass: jest.fn().mockImplementation(() => ({
getHistoryList: jest.fn().mockResolvedValue('mockHistoryList'),
})),
},
{
provide: SurveyMetaService,
useClass: jest.fn().mockImplementation(() => ({
checkSurveyAccess: jest.fn().mockResolvedValue({}),
})),
},
{
provide: Authtication,
useClass: jest.fn().mockImplementation(() => ({
canActivate: () => true,
})),
},
{
provide: UserService,
useClass: jest.fn().mockImplementation(() => ({
getUserByUsername() {
return {};
},
})),
},
],
}).compile();
controller = module.get<SurveyHistoryController>(SurveyHistoryController);
surveyHistoryService =
module.get<SurveyHistoryService>(SurveyHistoryService);
surveyMetaService = module.get<SurveyMetaService>(SurveyMetaService);
});
it('should return history list when query is valid', async () => {
const req = { user: { username: 'testUser' } };
const queryInfo = { surveyId: 'survey123', historyType: 'published' };
await controller.getList(queryInfo, req);
expect(surveyMetaService.checkSurveyAccess).toHaveBeenCalledWith({
surveyId: queryInfo.surveyId,
username: req.user.username,
});
expect(surveyHistoryService.getHistoryList).toHaveBeenCalledWith({
surveyId: queryInfo.surveyId,
historyType: queryInfo.historyType,
});
expect(surveyHistoryService.getHistoryList).toHaveBeenCalledTimes(1);
expect(surveyMetaService.checkSurveyAccess).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,121 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SurveyHistoryService } from '../services/surveyHistory.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { SurveyHistory } from 'src/models/surveyHistory.entity';
import { HISTORY_TYPE } from 'src/enums';
import { SurveySchemaInterface } from 'src/interfaces/survey';
import { ObjectId } from 'mongodb';
describe('SurveyHistoryService', () => {
let service: SurveyHistoryService;
let repository: MongoRepository<SurveyHistory>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SurveyHistoryService,
{
provide: getRepositoryToken(SurveyHistory),
useClass: MongoRepository,
},
],
}).compile();
service = module.get<SurveyHistoryService>(SurveyHistoryService);
repository = module.get<MongoRepository<SurveyHistory>>(
getRepositoryToken(SurveyHistory),
);
});
const mockSchema: SurveySchemaInterface = {
bannerConf: {
titleConfig: undefined,
bannerConfig: undefined,
},
dataConf: {
dataList: [],
},
submitConf: {
submitTitle: '',
confirmAgain: undefined,
msgContent: undefined,
},
baseConf: {
begTime: '',
endTime: '',
answerBegTime: '',
answerEndTime: '',
tLimit: 0,
language: '',
},
skinConf: undefined,
bottomConf: undefined,
};
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('addHistory', () => {
it('should add a new history record', async () => {
const surveyId = 'survey_id';
const schema = mockSchema;
const type = HISTORY_TYPE.DAILY_HIS;
const user = { _id: 'user_id', username: 'test_user' };
const spyCreate = jest.spyOn(repository, 'create').mockReturnValueOnce({
pageId: surveyId,
type,
schema,
operator: {
_id: user._id.toString(),
username: user.username,
},
} as SurveyHistory);
const spySave = jest
.spyOn(repository, 'save')
.mockResolvedValueOnce({} as SurveyHistory);
await service.addHistory({ surveyId, schema, type, user });
expect(spyCreate).toHaveBeenCalledWith({
pageId: surveyId,
type,
schema,
operator: {
_id: user._id.toString(),
username: user.username,
},
});
expect(spySave).toHaveBeenCalled();
});
});
describe('getHistoryList', () => {
it('should return a list of history records for a survey', async () => {
const surveyId = new ObjectId().toString();
const historyType = HISTORY_TYPE.DAILY_HIS;
const mockHistory = new SurveyHistory();
mockHistory.schema = mockSchema;
mockHistory.pageId = surveyId;
const expectedResult = [mockHistory];
const spyFind = jest
.spyOn(repository, 'find')
.mockResolvedValueOnce(expectedResult);
const result = await service.getHistoryList({ surveyId, historyType });
expect(result).toEqual(expectedResult);
expect(spyFind).toHaveBeenCalledWith({
where: {
pageId: surveyId,
type: historyType,
},
take: 100,
});
});
});
});

View File

@ -0,0 +1,200 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SurveyMetaController } from '../controllers/surveyMeta.controller';
import { SurveyMetaService } from '../services/surveyMeta.service';
import { Authtication } from 'src/guards/authtication';
import * as Joi from 'joi';
import { SurveyMeta } from 'src/models/surveyMeta.entity';
describe('SurveyMetaController', () => {
let controller: SurveyMetaController;
let surveyMetaService: SurveyMetaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SurveyMetaController],
providers: [
{
provide: SurveyMetaService,
useValue: {
checkSurveyAccess: jest.fn().mockResolvedValue({}),
editSurveyMeta: jest.fn().mockResolvedValue(undefined),
getSurveyMetaList: jest
.fn()
.mockResolvedValue({ count: 0, data: [] }),
},
},
],
})
.overrideGuard(Authtication)
.useValue({
canActivate: () => true,
})
.compile();
controller = module.get<SurveyMetaController>(SurveyMetaController);
surveyMetaService = module.get<SurveyMetaService>(SurveyMetaService);
});
it('should update survey meta', async () => {
const reqBody = {
remark: 'Test remark',
title: 'Test title',
surveyId: 'test-survey-id',
};
const req = {
user: {
username: 'test-user',
},
};
const survey = {
title: '',
remark: '',
};
jest
.spyOn(surveyMetaService, 'checkSurveyAccess')
.mockImplementation(() => {
return Promise.resolve(survey) as Promise<SurveyMeta>;
});
const result = await controller.updateMeta(reqBody, req);
expect(surveyMetaService.checkSurveyAccess).toHaveBeenCalledWith({
surveyId: reqBody.surveyId,
username: req.user.username,
});
expect(surveyMetaService.editSurveyMeta).toHaveBeenCalledWith({
title: reqBody.title,
remark: reqBody.remark,
});
expect(result).toEqual({ code: 200 });
});
it('should validate request body with Joi', async () => {
const reqBody = {
// Missing title and surveyId
};
const req = {
user: {
username: 'test-user',
},
};
try {
await controller.updateMeta(reqBody, req);
} catch (error) {
expect(error).toBeInstanceOf(Joi.ValidationError);
expect(error.details[0].message).toMatch('"title" is required');
}
expect(surveyMetaService.checkSurveyAccess).not.toHaveBeenCalled();
expect(surveyMetaService.editSurveyMeta).not.toHaveBeenCalled();
});
it('should get survey meta list', async () => {
const queryInfo = {
curPage: 1,
pageSize: 10,
};
const req = {
user: {
username: 'test-user',
},
};
try {
jest
.spyOn(surveyMetaService, 'getSurveyMetaList')
.mockImplementation(() => {
const date = new Date().getTime();
return Promise.resolve({
count: 10,
data: [
{
id: '1',
createDate: date,
updateDate: date,
curStatus: {
date: date,
},
},
],
});
});
const result = await controller.getList(queryInfo, req);
expect(result).toEqual({
code: 200,
data: {
count: 10,
data: expect.arrayContaining([
expect.objectContaining({
createDate: expect.stringMatching(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
),
updateDate: expect.stringMatching(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
),
curStatus: expect.objectContaining({
date: expect.stringMatching(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
),
}),
}),
]),
},
});
expect(surveyMetaService.getSurveyMetaList).toHaveBeenCalledWith({
pageNum: queryInfo.curPage,
pageSize: queryInfo.pageSize,
username: req.user.username,
filter: {},
order: {},
});
} catch (error) {
console.log(error);
}
});
it('should get survey meta list with filter and order', async () => {
const queryInfo = {
curPage: 1,
pageSize: 10,
filter: JSON.stringify([
{
comparator: '',
condition: [{ field: 'title', value: 'hahah', comparator: '$regex' }],
},
{
comparator: '',
condition: [{ field: 'surveyType', value: 'normal' }],
},
]),
order: JSON.stringify([{ field: 'createDate', value: -1 }]),
};
const req = {
user: {
username: 'test-user',
},
};
try {
const result = await controller.getList(queryInfo, req);
expect(result.code).toEqual(200);
expect(surveyMetaService.getSurveyMetaList).toHaveBeenCalledWith({
pageNum: queryInfo.curPage,
pageSize: queryInfo.pageSize,
username: req.user.username,
filter: { surveyType: 'normal', title: { $regex: 'hahah' } },
order: { createDate: -1 },
});
} catch (error) {
console.log(error);
}
});
});

View File

@ -0,0 +1,264 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SurveyMetaService } from '../services/surveyMeta.service';
import { MongoRepository } from 'typeorm';
import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { ObjectId } from 'mongodb';
import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider';
import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager';
import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';
import { NoSurveyPermissionException } from 'src/exceptions/noSurveyPermissionException';
import { RECORD_STATUS } from 'src/enums';
import { getRepositoryToken } from '@nestjs/typeorm';
import { HttpException } from 'src/exceptions/httpException';
import { SurveyUtilPlugin } from 'src/securityPlugin/surveyUtilPlugin';
describe('SurveyMetaService', () => {
let service: SurveyMetaService;
let surveyRepository: MongoRepository<SurveyMeta>;
let pluginManager: XiaojuSurveyPluginManager;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SurveyMetaService,
{
provide: getRepositoryToken(SurveyMeta),
useValue: {
findOne: jest.fn(),
count: jest.fn(),
create: jest.fn(),
save: jest.fn(),
findAndCount: jest.fn(),
},
},
PluginManagerProvider,
],
}).compile();
service = module.get<SurveyMetaService>(SurveyMetaService);
surveyRepository = module.get<MongoRepository<SurveyMeta>>(
getRepositoryToken(SurveyMeta),
);
pluginManager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager,
);
pluginManager.registerPlugin(new SurveyUtilPlugin());
});
describe('getNewSurveyPath', () => {
it('should generate a new survey path', async () => {
jest.spyOn(surveyRepository, 'count').mockResolvedValueOnce(1);
jest.spyOn(surveyRepository, 'count').mockResolvedValueOnce(0);
const surveyPath = await service.getNewSurveyPath();
expect(typeof surveyPath).toBe('string');
expect(surveyRepository.count).toHaveBeenCalledTimes(2);
});
});
describe('checkSurveyAccess', () => {
it('should return survey when user has access', async () => {
const surveyId = new ObjectId().toHexString();
const username = 'testUser';
const survey = { owner: username } as SurveyMeta;
jest.spyOn(surveyRepository, 'findOne').mockResolvedValue(survey);
const result = await service.checkSurveyAccess({ surveyId, username });
expect(result).toBe(survey);
expect(surveyRepository.findOne).toHaveBeenCalledWith({
where: { _id: new ObjectId(surveyId) },
});
});
it('should throw SurveyNotFoundException when survey not found', async () => {
const surveyId = new ObjectId().toHexString();
const username = 'testUser';
jest.spyOn(surveyRepository, 'findOne').mockResolvedValue(null);
await expect(
service.checkSurveyAccess({ surveyId, username }),
).rejects.toThrow(SurveyNotFoundException);
expect(surveyRepository.findOne).toHaveBeenCalledWith({
where: { _id: new ObjectId(surveyId) },
});
});
it('should throw NoSurveyPermissionException when user has no access', async () => {
const surveyId = new ObjectId().toHexString();
const username = 'testUser';
const surveyOwner = 'otherUser';
const survey = { owner: surveyOwner } as SurveyMeta;
jest.spyOn(surveyRepository, 'findOne').mockResolvedValue(survey);
await expect(
service.checkSurveyAccess({ surveyId, username }),
).rejects.toThrow(NoSurveyPermissionException);
expect(surveyRepository.findOne).toHaveBeenCalledWith({
where: { _id: new ObjectId(surveyId) },
});
});
});
describe('createSurveyMeta', () => {
it('should create a new survey meta and return it', async () => {
const params = {
title: 'Test Survey',
remark: 'This is a test survey',
surveyType: 'normal',
username: 'testUser',
createMethod: '',
createFrom: '',
};
const newSurvey = new SurveyMeta();
const mockedSurveyPath = 'mockedSurveyPath';
jest
.spyOn(service, 'getNewSurveyPath')
.mockResolvedValue(mockedSurveyPath);
jest
.spyOn(surveyRepository, 'create')
.mockImplementation(() => newSurvey);
jest.spyOn(surveyRepository, 'save').mockResolvedValue(newSurvey);
const result = await service.createSurveyMeta(params);
expect(surveyRepository.create).toHaveBeenCalledWith({
title: params.title,
remark: params.remark,
surveyType: params.surveyType,
surveyPath: mockedSurveyPath,
creator: params.username,
owner: params.username,
createMethod: params.createMethod,
createFrom: params.createFrom,
});
expect(surveyRepository.save).toHaveBeenCalledWith(newSurvey);
expect(result).toEqual(newSurvey);
});
});
describe('editSurveyMeta', () => {
it('should edit a survey meta and return it if in NEW or EDITING status', async () => {
const survey = new SurveyMeta();
survey.curStatus = { status: RECORD_STATUS.PUBLISHED, date: Date.now() };
survey.statusList = [];
jest.spyOn(surveyRepository, 'save').mockResolvedValue(survey);
const result = await service.editSurveyMeta(survey);
expect(survey.curStatus.status).toEqual(RECORD_STATUS.EDITING);
expect(survey.statusList.length).toBe(1);
expect(survey.statusList[0].status).toEqual(RECORD_STATUS.EDITING);
expect(surveyRepository.save).toHaveBeenCalledWith(survey);
expect(result).toEqual(survey);
});
});
describe('deleteSurveyMeta', () => {
it('should delete survey meta and update status', async () => {
// 准备假的SurveyMeta对象
const survey = new SurveyMeta();
survey.curStatus = { status: RECORD_STATUS.NEW, date: Date.now() };
survey.statusList = [];
// 模拟save方法
jest.spyOn(surveyRepository, 'save').mockResolvedValue(survey);
// 调用要测试的方法
const result = await service.deleteSurveyMeta(survey);
// 验证结果
expect(result).toBe(survey);
expect(survey.curStatus.status).toBe(RECORD_STATUS.REMOVED);
expect(survey.statusList.length).toBe(1);
expect(survey.statusList[0].status).toBe(RECORD_STATUS.REMOVED);
expect(surveyRepository.save).toHaveBeenCalledTimes(1);
expect(surveyRepository.save).toHaveBeenCalledWith(survey);
});
it('should throw exception when survey is already removed', async () => {
// 准备假的SurveyMeta对象其状态已设置为REMOVED
const survey = new SurveyMeta();
survey.curStatus = { status: RECORD_STATUS.REMOVED, date: Date.now() };
// 调用要测试的方法并期待异常
await expect(service.deleteSurveyMeta(survey)).rejects.toThrow(
HttpException,
);
// 验证save方法没有被调用
expect(surveyRepository.save).not.toHaveBeenCalled();
});
});
describe('getSurveyMetaList', () => {
it('should return a list of survey metadata', async () => {
// 准备模拟数据
const mockData = [
{ _id: 1, title: 'Survey 1' },
{ _id: 2, title: 'Survey 2' },
] as unknown as Array<SurveyMeta>;
const mockCount = 2;
jest
.spyOn(surveyRepository, 'findAndCount')
.mockResolvedValue([mockData, mockCount]);
// 调用方法并检查返回值
const condition = {
pageNum: 1,
pageSize: 10,
username: 'testUser',
filter: {},
order: {},
};
const result = await service.getSurveyMetaList(condition);
// 验证返回值
expect(result).toEqual({ data: mockData, count: mockCount });
// 验证repository方法被正确调用
expect(surveyRepository.findAndCount).toHaveBeenCalledWith({
where: {
owner: 'testUser',
'curStatus.status': { $ne: 'removed' },
},
skip: 0,
take: 10,
order: { createDate: -1 },
});
});
});
describe('publishSurveyMeta', () => {
it('should publish a survey meta and add status to statusList', async () => {
// 准备模拟数据
const surveyMeta = {
id: 1,
title: 'Test Survey',
statusList: [],
} as unknown as SurveyMeta;
const savedSurveyMeta = {
...surveyMeta,
curStatus: {
status: RECORD_STATUS.PUBLISHED,
date: expect.any(Number),
},
} as unknown as SurveyMeta;
jest.spyOn(surveyRepository, 'save').mockResolvedValue(savedSurveyMeta);
// 调用方法并检查返回值
const result = await service.publishSurveyMeta({ surveyMeta });
// 验证返回值
expect(result).toEqual(savedSurveyMeta);
// 验证repository方法被正确调用
expect(surveyRepository.save).toHaveBeenCalledWith(savedSurveyMeta);
});
});
});

View File

@ -0,0 +1,32 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SurveyUIController } from '../controllers/surveyUI.controller';
import { Response } from 'express';
import { join } from 'path';
describe('SurveyUIController', () => {
let controller: SurveyUIController;
let res: Response;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SurveyUIController],
}).compile();
controller = module.get<SurveyUIController>(SurveyUIController);
res = {
sendFile: jest.fn().mockResolvedValue(undefined),
} as unknown as Response;
});
it('should send the correct file for the home route', () => {
controller.home(res);
const expectedPath = join(process.cwd(), 'public', 'management.html');
expect(res.sendFile).toHaveBeenCalledWith(expectedPath);
});
it('should send the correct file for the management route', () => {
controller.management(res);
const expectedPath = join(process.cwd(), 'public', 'management.html');
expect(res.sendFile).toHaveBeenCalledWith(expectedPath);
});
});

View File

@ -15,21 +15,7 @@ import * as Joi from 'joi';
import { Authtication } from 'src/guards/authtication'; import { Authtication } from 'src/guards/authtication';
import moment from 'moment'; import moment from 'moment';
type FilterItem = { import { getFilter, getOrder } from 'src/utils/surveyUtil';
comparator?: string;
condition: Array<FilterCondition>;
};
type FilterCondition = {
field: string;
comparator?: string;
value: string & Array<FilterItem>;
};
type OrderItem = {
field: string;
value: number;
};
@Controller('/api/survey') @Controller('/api/survey')
export class SurveyMetaController { export class SurveyMetaController {
@ -83,7 +69,7 @@ export class SurveyMetaController {
order = {}; order = {};
if (validationResult.filter) { if (validationResult.filter) {
try { try {
filter = this.getFilter( filter = getFilter(
JSON.parse(decodeURIComponent(validationResult.filter)), JSON.parse(decodeURIComponent(validationResult.filter)),
); );
} catch (error) { } catch (error) {
@ -92,7 +78,7 @@ export class SurveyMetaController {
} }
if (validationResult.order) { if (validationResult.order) {
try { try {
order = order = this.getOrder( order = order = getOrder(
JSON.parse(decodeURIComponent(validationResult.order)), JSON.parse(decodeURIComponent(validationResult.order)),
); );
} catch (error) { } catch (error) {
@ -124,62 +110,4 @@ export class SurveyMetaController {
}, },
}; };
} }
private getFilter(filterList: Array<FilterItem>) {
const allowFilterField = [
'title',
'remark',
'surveyType',
'curStatus.status',
];
return filterList.reduce(
(preItem, curItem) => {
const condition = curItem.condition
.filter((item) => allowFilterField.includes(item.field))
.reduce((pre, cur) => {
switch (cur.comparator) {
case '$ne':
pre[cur.field] = {
$ne: cur.value,
};
break;
case '$regex':
pre[cur.field] = {
$regex: cur.value,
};
break;
default:
pre[cur.field] = cur.value;
break;
}
return pre;
}, {});
switch (curItem.comparator) {
case '$or':
if (!Array.isArray(preItem.$or)) {
preItem.$or = [];
}
preItem.$or.push(condition);
break;
default:
Object.assign(preItem, condition);
break;
}
return preItem;
},
{} as { $or?: Array<Record<string, string>> } & Record<string, string>,
);
}
private getOrder(order: Array<OrderItem>) {
const allowOrderFields = ['createDate', 'updateDate', 'curStatus.date'];
const orderList = order.filter((orderItem) =>
allowOrderFields.includes(orderItem.field),
);
return orderList.reduce((pre, cur) => {
pre[cur.field] = cur.value === 1 ? 1 : -1;
return pre;
}, {});
}
} }

View File

@ -1,4 +1,4 @@
import { Controller, Get, Param, Res } from '@nestjs/common'; import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { join } from 'path'; import { join } from 'path';
@ -11,8 +11,8 @@ export class SurveyUIController {
res.sendFile(join(process.cwd(), 'public', 'management.html')); res.sendFile(join(process.cwd(), 'public', 'management.html'));
} }
@Get('/management/:surveyId') @Get('/management/:path*')
management(@Param('surveyId') surveyId: string, @Res() res: Response) { management(@Res() res: Response) {
res.sendFile(join(process.cwd(), 'public', 'management.html')); res.sendFile(join(process.cwd(), 'public', 'management.html'));
} }
} }

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';
@Injectable() @Injectable()
export class DataStatisticService { export class DataStatisticService {
constructor( constructor(
@ -15,46 +15,6 @@ export class DataStatisticService {
private readonly surveyResponseRepository: MongoRepository<SurveyResponse>, private readonly surveyResponseRepository: MongoRepository<SurveyResponse>,
) {} ) {}
private getListHeadByDataList(dataList) {
const listHead = dataList.map((question) => {
let othersCode;
if (question.type === 'radio-star') {
const rangeConfigKeys = Object.keys(question.rangeConfig);
if (rangeConfigKeys.length > 0) {
othersCode = [
{ code: `${question.field}_custom`, option: '填写理由' },
];
}
} else {
othersCode = (question.options || [])
.filter((optionItem) => optionItem.othersKey)
.map((optionItem) => {
return {
code: optionItem.othersKey,
option: optionItem.text,
};
});
}
return {
field: question.field,
title: question.title,
type: question.type,
othersCode,
};
});
listHead.push({
field: 'difTime',
title: '答题耗时(秒)',
type: 'text',
});
listHead.push({
field: 'createDate',
title: '提交时间',
type: 'text',
});
return listHead;
}
async getDataTable({ async getDataTable({
surveyId, surveyId,
pageNum, pageNum,
@ -67,7 +27,7 @@ export class DataStatisticService {
responseSchema: ResponseSchema; responseSchema: ResponseSchema;
}) { }) {
const dataList = responseSchema?.code?.dataConf?.dataList || []; const dataList = responseSchema?.code?.dataConf?.dataList || [];
const listHead = this.getListHeadByDataList(dataList); const listHead = getListHeadByDataList(dataList);
const dataListMap = keyBy(dataList, 'field'); const dataListMap = keyBy(dataList, 'field');
const where = { const where = {
pageId: surveyId, pageId: surveyId,

View File

@ -2,25 +2,11 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm'; import { MongoRepository } from 'typeorm';
import { SurveyConf } from 'src/models/surveyConf.entity'; import { SurveyConf } from 'src/models/surveyConf.entity';
import templateBase from '../template/surveyTemplate/templateBase.json';
import normalCode from '../template/surveyTemplate/survey/normal.json';
import npsCode from '../template/surveyTemplate/survey/nps.json';
import registerCode from '../template/surveyTemplate/survey/register.json';
import voteCode from '../template/surveyTemplate/survey/vote.json';
import { get } from 'lodash';
import moment from 'moment';
import { HttpException } from 'src/exceptions/httpException'; import { HttpException } from 'src/exceptions/httpException';
import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { SurveySchemaInterface } from 'src/interfaces/survey'; import { SurveySchemaInterface } from 'src/interfaces/survey';
import { getSchemaBySurveyType } from '../utils';
const schemaDataMap = {
normal: normalCode,
nps: npsCode,
register: registerCode,
vote: voteCode,
};
@Injectable() @Injectable()
export class SurveyConfService { export class SurveyConfService {
@ -29,24 +15,6 @@ export class SurveyConfService {
private readonly surveyConfRepository: MongoRepository<SurveyConf>, private readonly surveyConfRepository: MongoRepository<SurveyConf>,
) {} ) {}
private async getSchemaBySurveyType(surveyType: string) {
// Implement your logic here
const codeData = get(schemaDataMap, surveyType);
if (!codeData) {
throw new HttpException(
'问卷类型不存在',
EXCEPTION_CODE.SURVEY_TYPE_ERROR,
);
}
const code = Object.assign({}, templateBase, codeData);
const nowMoment = moment();
code.baseConf.begTime = nowMoment.format('YYYY-MM-DD HH:mm:ss');
code.baseConf.endTime = nowMoment
.add(10, 'years')
.format('YYYY-MM-DD HH:mm:ss');
return code;
}
async createSurveyConf(params: { async createSurveyConf(params: {
surveyId: string; surveyId: string;
surveyType: string; surveyType: string;
@ -59,7 +27,14 @@ export class SurveyConfService {
const codeInfo = await this.getSurveyConfBySurveyId(createFrom); const codeInfo = await this.getSurveyConfBySurveyId(createFrom);
schemaData = codeInfo.code; schemaData = codeInfo.code;
} else { } else {
schemaData = await this.getSchemaBySurveyType(surveyType); try {
schemaData = await getSchemaBySurveyType(surveyType);
} catch (error) {
throw new HttpException(
error.message,
EXCEPTION_CODE.SURVEY_TYPE_ERROR,
);
}
} }
const newCode = this.surveyConfRepository.create({ const newCode = this.surveyConfRepository.create({

View File

@ -18,8 +18,8 @@ export class SurveyMetaService {
private readonly pluginManager: XiaojuSurveyPluginManager, private readonly pluginManager: XiaojuSurveyPluginManager,
) {} ) {}
private async getNewSurveyPath(): Promise<string> { async getNewSurveyPath(): Promise<string> {
let surveyPath = this.pluginManager.triggerHook('genSurveyPath'); let surveyPath = await this.pluginManager.triggerHook('genSurveyPath');
while (true) { while (true) {
const count = await this.surveyRepository.count({ const count = await this.surveyRepository.count({
where: { where: {
@ -29,7 +29,7 @@ export class SurveyMetaService {
if (count === 0) { if (count === 0) {
break; break;
} }
surveyPath = this.pluginManager.triggerHook('genSurveyPath'); surveyPath = await this.pluginManager.triggerHook('genSurveyPath');
} }
return surveyPath; return surveyPath;
} }

View File

@ -0,0 +1,67 @@
import { get } from 'lodash';
import moment from 'moment';
import templateBase from '../template/surveyTemplate/templateBase.json';
import normalCode from '../template/surveyTemplate/survey/normal.json';
import npsCode from '../template/surveyTemplate/survey/nps.json';
import registerCode from '../template/surveyTemplate/survey/register.json';
import voteCode from '../template/surveyTemplate/survey/vote.json';
const schemaDataMap = {
normal: normalCode,
nps: npsCode,
register: registerCode,
vote: voteCode,
};
export async function getSchemaBySurveyType(surveyType: string) {
// Implement your logic here
const codeData = get(schemaDataMap, surveyType);
if (!codeData) {
throw new Error('问卷类型不存在');
}
const code = Object.assign({}, templateBase, codeData);
const nowMoment = moment();
code.baseConf.begTime = nowMoment.format('YYYY-MM-DD HH:mm:ss');
code.baseConf.endTime = nowMoment
.add(10, 'years')
.format('YYYY-MM-DD HH:mm:ss');
return code;
}
export function getListHeadByDataList(dataList) {
const listHead = dataList.map((question) => {
let othersCode;
if (question.type === 'radio-star') {
const rangeConfigKeys = Object.keys(question.rangeConfig);
if (rangeConfigKeys.length > 0) {
othersCode = [{ code: `${question.field}_custom`, option: '填写理由' }];
}
} else {
othersCode = (question.options || [])
.filter((optionItem) => optionItem.othersKey)
.map((optionItem) => {
return {
code: optionItem.othersKey,
option: optionItem.text,
};
});
}
return {
field: question.field,
title: question.title,
type: question.type,
othersCode,
};
});
listHead.push({
field: 'difTime',
title: '答题耗时(秒)',
type: 'text',
});
listHead.push({
field: 'createDate',
title: '提交时间',
type: 'text',
});
return listHead;
}

View File

@ -0,0 +1,119 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MongoRepository } from 'typeorm';
import { ClientEncryptService } from '../services/clientEncrypt.service';
import { ClientEncrypt } from 'src/models/clientEncrypt.entity';
import { ENCRYPT_TYPE } from 'src/enums/encrypt';
import { RECORD_STATUS } from 'src/enums';
import { ObjectId } from 'mongodb';
import { getRepositoryToken } from '@nestjs/typeorm';
describe('ClientEncryptService', () => {
let service: ClientEncryptService;
let repository: MongoRepository<ClientEncrypt>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClientEncryptService,
{
provide: getRepositoryToken(ClientEncrypt),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
updateOne: jest.fn(),
},
},
],
}).compile();
service = module.get<ClientEncryptService>(ClientEncryptService);
repository = module.get<MongoRepository<ClientEncrypt>>(
getRepositoryToken(ClientEncrypt),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('addAes', () => {
it('should save AES encrypt info', async () => {
const secretKey = 'my-secret-key';
const encryptInfo = {
data: { secretKey },
type: ENCRYPT_TYPE.AES,
} as ClientEncrypt;
jest.spyOn(repository, 'create').mockReturnValue(encryptInfo);
jest.spyOn(repository, 'save').mockResolvedValue(encryptInfo);
const result = await service.addAes({ secretKey });
expect(repository.create).toHaveBeenCalledWith(encryptInfo);
expect(repository.save).toHaveBeenCalledWith(encryptInfo);
expect(result).toEqual(encryptInfo);
});
});
describe('addRsa', () => {
it('should save RSA encrypt info', async () => {
const publicKey = 'my-public-key';
const privateKey = 'my-private-key';
const encryptInfo = {
data: { publicKey, privateKey },
type: ENCRYPT_TYPE.RSA,
} as ClientEncrypt;
jest.spyOn(repository, 'create').mockReturnValue(encryptInfo);
jest.spyOn(repository, 'save').mockResolvedValue(encryptInfo);
const result = await service.addRsa({ publicKey, privateKey });
expect(repository.create).toHaveBeenCalledWith(encryptInfo);
expect(repository.save).toHaveBeenCalledWith(encryptInfo);
expect(result).toEqual(encryptInfo);
});
});
describe('getEncryptInfoById', () => {
it('should return encrypt info by id', async () => {
const id = new ObjectId().toHexString();
const encryptInfo = {
id,
type: ENCRYPT_TYPE.AES,
} as unknown as ClientEncrypt;
jest.spyOn(repository, 'findOne').mockResolvedValue(encryptInfo);
const result = await service.getEncryptInfoById(id);
expect(repository.findOne).toHaveBeenCalledWith({
where: {
_id: new ObjectId(id),
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
},
});
expect(result).toEqual(encryptInfo);
});
it('should return null if encrypt info not found', async () => {
const id = new ObjectId().toHexString();
jest.spyOn(repository, 'findOne').mockResolvedValue(null);
const result = await service.getEncryptInfoById(id);
expect(result).toBeNull();
});
});
describe('deleteEncryptInfo', () => {
it('should delete encrypt info by id', async () => {
const id = new ObjectId().toHexString();
const updateResult = { matchedCount: 1, modifiedCount: 1 };
jest.spyOn(repository, 'updateOne').mockResolvedValue(updateResult);
const result = await service.deleteEncryptInfo(id);
expect(result).toEqual(updateResult);
});
});
});

View File

@ -0,0 +1,103 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CounterService } from '../services/counter.service';
import { MongoRepository } from 'typeorm';
import { Counter } from 'src/models/counter.entity';
import { getRepositoryToken } from '@nestjs/typeorm';
describe('CounterService', () => {
let service: CounterService;
let counterRepository: MongoRepository<Counter>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CounterService,
{
provide: getRepositoryToken(Counter),
useValue: {
updateOne: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
},
},
],
}).compile();
service = module.get<CounterService>(CounterService);
counterRepository = module.get<MongoRepository<Counter>>(
getRepositoryToken(Counter),
);
});
it('should update counter data', async () => {
const data = { someData: 'someValue' };
const updateResult = { rawResponse: { matchedCount: 1, modifiedCount: 1 } };
jest.spyOn(counterRepository, 'updateOne').mockResolvedValue(updateResult);
const result = await service.set({
surveyPath: 'testPath',
key: 'testKey',
data,
type: 'testType',
});
expect(result).toEqual(updateResult);
expect(counterRepository.updateOne).toHaveBeenCalledWith(
{ key: 'testKey', surveyPath: 'testPath', type: 'testType' },
{
$set: {
key: 'testKey',
surveyPath: 'testPath',
type: 'testType',
data,
},
},
{ upsert: true },
);
});
it('should get counter data', async () => {
const expectedData = { someData: 'someValue' };
const counterEntity = new Counter();
counterEntity.data = expectedData;
jest.spyOn(counterRepository, 'findOne').mockResolvedValue(counterEntity);
const result = await service.get({
surveyPath: 'testPath',
key: 'testKey',
type: 'testType',
});
expect(result).toEqual(expectedData);
expect(counterRepository.findOne).toHaveBeenCalledWith({
where: { key: 'testKey', surveyPath: 'testPath', type: 'testType' },
});
});
it('should get all counter data', async () => {
const expectedData = {
key1: { someData: 'value1' },
key2: { someData: 'value2' },
};
const counterEntities = [
{ key: 'key1', data: expectedData.key1 },
{ key: 'key2', data: expectedData.key2 },
] as unknown as Array<Counter>;
jest.spyOn(counterRepository, 'find').mockResolvedValue(counterEntities);
const result = await service.getAll({
surveyPath: 'testPath',
keyList: ['key1', 'key2'],
type: 'testType',
});
expect(result).toEqual(expectedData);
expect(counterRepository.find).toHaveBeenCalledWith({
where: {
key: { $in: ['key1', 'key2'] },
surveyPath: 'testPath',
type: 'testType',
},
});
});
});

View File

@ -0,0 +1,239 @@
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { RECORD_STATUS } from 'src/enums';
import { ObjectId } from 'mongodb';
export const mockResponseSchema: ResponseSchema = {
_id: new ObjectId('65f29f8892862d6a9067ad25'),
curStatus: {
status: RECORD_STATUS.PUBLISHED,
date: 1710399368439,
},
statusList: [
{
status: RECORD_STATUS.PUBLISHED,
date: 1710399368439,
},
],
createDate: 1710399368440,
updateDate: 1710399368440,
title: '加密全流程',
surveyPath: 'EBzdmnSp',
code: {
bannerConf: {
titleConfig: {
mainTitle:
'<h3 style="text-align: center">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style="color: rgb(204, 0, 0)">期待您的参与!</span></p>',
subTitle: '',
},
bannerConfig: {
bgImage: '/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp',
videoLink: '',
postImg: '',
},
},
baseConf: {
begTime: '2024-03-14 14:54:41',
endTime: '2034-03-14 14:54:41',
language: 'chinese',
tLimit: 10,
answerBegTime: '',
answerEndTime: '',
},
bottomConf: {
logoImage: '/imgs/Logo.webp',
logoImageWidth: '60%',
},
skinConf: {
skinColor: '#4a4c5b',
inputBgColor: '#ffffff',
},
submitConf: {
submitTitle: '提交',
msgContent: {
msg_200: '提交成功',
msg_9001: '您来晚了,感谢支持问卷~',
msg_9002: '请勿多次提交!',
msg_9003: '您来晚了,已经满额!',
msg_9004: '提交失败!',
},
confirmAgain: {
is_again: true,
again_text: '确认要提交吗?',
},
},
dataConf: {
dataList: [
{
isRequired: true,
showIndex: true,
showType: true,
showSpliter: true,
type: 'text',
valid: '',
field: 'data458',
title: '<p>您的手机号</p>',
placeholder: '',
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
star: 5,
nps: {
leftText: '极不满意',
rightText: '极满意',
},
placeholderDesc: '',
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
isRequired: true,
showIndex: true,
showType: true,
showSpliter: true,
type: 'radio',
placeholderDesc: '',
field: 'data515',
title: '<p>您的性别</p>',
placeholder: '',
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
options: [
{
text: '<p>男</p>',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
hash: '115019',
},
{
text: '<p>女</p>',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
hash: '115020',
},
],
importKey: 'single',
importData: '',
cOption: '',
cOptions: [],
nps: {
leftText: '极不满意',
rightText: '极满意',
},
star: 5,
exclude: false,
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
field: 'data450',
showIndex: true,
showType: true,
showSpliter: true,
type: 'text',
placeholderDesc: '',
title: '<p>身份证</p>',
placeholder: '',
valid: '',
isRequired: true,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {},
star: 5,
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
field: 'data405',
showIndex: true,
showType: true,
showSpliter: true,
type: 'text',
placeholderDesc: '',
title: '<p>地址</p>',
placeholder: '',
valid: '',
isRequired: true,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {},
star: 5,
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
field: 'data770',
showIndex: true,
showType: true,
showSpliter: true,
type: 'text',
placeholderDesc: '',
title: '<p>邮箱</p>',
placeholder: '',
valid: '',
isRequired: true,
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
starStyle: 'star',
rangeConfig: {},
star: 5,
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
],
},
},
pageId: '65f29f3192862d6a9067ad1c',
} as ResponseSchema;

View File

@ -0,0 +1,141 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ResponseSchemaService } from '../services/responseScheme.service';
import { MongoRepository } from 'typeorm';
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { mockResponseSchema } from './mockResponseSchema';
import { getRepositoryToken } from '@nestjs/typeorm';
import { cloneDeep } from 'lodash';
describe('ResponseSchemaService', () => {
let service: ResponseSchemaService;
let responseSchemaRepository: MongoRepository<ResponseSchema>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ResponseSchemaService,
{
provide: getRepositoryToken(ResponseSchema),
useValue: {
findOne: jest.fn().mockResolvedValue(mockResponseSchema),
create: jest.fn(),
save: jest.fn(),
},
},
],
}).compile();
service = module.get<ResponseSchemaService>(ResponseSchemaService);
responseSchemaRepository = module.get<MongoRepository<ResponseSchema>>(
getRepositoryToken(ResponseSchema),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('publishResponseSchema', () => {
it('should update existing response schema', async () => {
jest
.spyOn(responseSchemaRepository, 'save')
.mockResolvedValueOnce(undefined);
const params = {
title: 'testTitle',
surveyPath: mockResponseSchema.surveyPath,
code: {},
pageId: mockResponseSchema.pageId,
};
await service.publishResponseSchema(params);
expect(responseSchemaRepository.findOne).toHaveBeenCalledWith({
where: {
surveyPath: params.surveyPath,
},
});
expect(responseSchemaRepository.create).toHaveBeenCalledTimes(0);
expect(responseSchemaRepository.save).toHaveBeenCalledTimes(1);
});
it('should create new response schema if not exists', async () => {
jest
.spyOn(responseSchemaRepository, 'findOne')
.mockResolvedValueOnce(null);
const params = {
title: 'testTitle',
surveyPath: mockResponseSchema.surveyPath,
code: {},
pageId: mockResponseSchema.pageId,
};
await service.publishResponseSchema(params);
expect(responseSchemaRepository.findOne).toHaveBeenCalledWith({
where: {
surveyPath: params.surveyPath,
},
});
expect(responseSchemaRepository.create).toHaveBeenCalledTimes(1);
expect(responseSchemaRepository.save).toHaveBeenCalledTimes(1);
});
});
describe('getResponseSchemaByPath', () => {
it('should return response schema by survey path', async () => {
jest
.spyOn(responseSchemaRepository, 'findOne')
.mockResolvedValueOnce(mockResponseSchema);
const result = await service.getResponseSchemaByPath(
mockResponseSchema.surveyPath,
);
expect(result).toEqual(mockResponseSchema);
expect(responseSchemaRepository.findOne).toHaveBeenCalledWith({
where: {
surveyPath: mockResponseSchema.surveyPath,
},
});
});
});
describe('getResponseSchemaByPageId', () => {
it('should return response schema by page ID', async () => {
jest
.spyOn(responseSchemaRepository, 'findOne')
.mockResolvedValueOnce(mockResponseSchema);
const result = await service.getResponseSchemaByPageId(
mockResponseSchema.pageId,
);
expect(result).toEqual(mockResponseSchema);
expect(responseSchemaRepository.findOne).toHaveBeenCalledWith({
where: { pageId: mockResponseSchema.pageId },
});
});
});
describe('deleteResponseSchema', () => {
it('should delete response schema by survey path', async () => {
jest
.spyOn(responseSchemaRepository, 'findOne')
.mockResolvedValueOnce(cloneDeep(mockResponseSchema));
jest
.spyOn(responseSchemaRepository, 'save')
.mockResolvedValueOnce(undefined);
await service.deleteResponseSchema({
surveyPath: mockResponseSchema.surveyPath,
});
expect(responseSchemaRepository.findOne).toHaveBeenCalledWith({
where: {
surveyPath: mockResponseSchema.surveyPath,
},
});
expect(responseSchemaRepository.save).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -4,34 +4,105 @@ import { ResponseSchemaService } from '../services/responseScheme.service';
import { CounterService } from '../services/counter.service'; import { CounterService } from '../services/counter.service';
import { SurveyResponseService } from '../services/surveyResponse.service'; import { SurveyResponseService } from '../services/surveyResponse.service';
import { ClientEncryptService } from '../services/clientEncrypt.service'; import { ClientEncryptService } from '../services/clientEncrypt.service';
import { mockResponseSchema } from './mockResponseSchema';
import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider';
import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager';
import { ObjectId } from 'mongodb';
import { HttpException } from 'src/exceptions/httpException';
import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';
import { cloneDeep } from 'lodash';
import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin';
import { ResponseSchema } from 'src/models/responseSchema.entity'; const mockDecryptErrorBody = {
import { ClientEncrypt } from 'src/models/clientEncrypt.entity'; surveyPath: 'EBzdmnSp',
import { RECORD_STATUS } from 'src/enums'; data: [
'SkyfsbS6MDvFrrxFJQDMxsvm53G3PTURktfZikJP2fKilC8wPW5ZdfX29Fixor5ldHBBNyILsDtxhbNahEbNCDw8n1wS8IIckFuQcaJtn6MLOD+h+Iuywka3ig4ecTN87RpdcfEQe7f38rSSx0zoFU8j37eojjSF7eETBSrz5m9WaNesQo4hhC6p7wmAo1jggkdbG8PVrFqrZPbkHN5jOBrzQEqdqYu9A5wHMM7nUteqlPpkiogEDYmBIccqmPdtO54y7LoPslXgXj6jNja8oVNaYlnp7UsisT+i5cuQ7lbDukEhrfpAIFRsT2IUwVlLjWHtFQm4/4I5HyvVBirTng==',
'IMn0E7R6cYCQPI497mz3x99CPA4cImAFEfIv8Q98Gm5bFcgKJX6KFYS7PF/VtIuI1leKwwNYexQy7+2HnF40by/huVugoPYnPd4pTpUdG6f1kh8EpzIir2+8P98Dcz2+NZ/khP2RIAM8nOr+KSC99TNGhuKaKQCItyLFDkr80s3zv+INieGc8wULIrGoWDJGN2KdU/jSq+hkV0QXypd81N5IyAoNhZLkZeM/FU6grGFPnGRtcDDc5W8YWVHO87VymlxPCTRawXRTDcvGIUqb3GuZfxvA7AULqbspmN9kzt3rktuZLNb2TFQDsJfqUuCmi+b28qP/G4OrT9/VAHhYKw==',
],
difTime: 806707,
clientTime: 1710400229573,
encryptType: 'rsa',
sessionId: '65f2664c92862d6a9067ad18',
sign: '8c9ca8804c9d94de6055d68a1f3c423fe50c95b4bd69f809ee2da8fcd82fd960.1710400229589',
};
import * as aes from 'crypto-js/aes'; const mockSubmitData = {
surveyPath: 'EBzdmnSp',
data: [
'SkyfsbS6MDvFrrxFJQDMxsvm53G3PTURktfZikJP2fKilC8wPW5ZdfX29Fixor5ldHBBNyILsDtxhbNahEbNCDw8n1wS8IIckFuQcaJtn6MLOD+h+Iuywka3ig4ecTN87RpdcfEQe7f38rSSx0zoFU8j37eojjSF7eETBSrz5m9WaNesQo4hhC6p7wmAo1jggkdbG8PVrFqrZPbkHN5jOBrzQEqdqYu9A5wHMM7nUteqlPpkiogEDYmBIccqmPdtO54y7LoPslXgXj6jNja8oVNaYlnp7UsisT+i5cuQ7lbDukEhrfpAIFRsT2IUwVlLjWHtFQm4/4I5HyvVBirTng==',
'IMn0E7R6cYCQPI497mz3x99CPA4cImAFEfIv8Q98Gm5bFcgKJX6KFYS7PF/VtIuI1leKwwNYexQy7+2HnF40by/huVugoPYnPd4pTpUdG6f1kh8EpzIir2+8P98Dcz2+NZ/khP2RIAM8nOr+KSC99TNGhuKaKQCItyLFDkr80s3zv+INieGc8wULIrGoWDJGN2KdU/jSq+hkV0QXypd81N5IyAoNhZLkZeM/FU6grGFPnGRtcDDc5W8YWVHO87VymlxPCTRawXRTDcvGIUqb3GuZfxvA7AULqbspmN9kzt3rktuZLNb2TFQDsJfqUuCmi+b28qP/G4OrT9/VAHhYKw==',
],
difTime: 806707,
clientTime: 1710400229573,
encryptType: 'rsa',
sessionId: '65f29fc192862d6a9067ad28',
sign: '8c9ca8804c9d94de6055d68a1f3c423fe50c95b4bd69f809ee2da8fcd82fd960.1710400229589',
};
jest.mock('../services/responseScheme.service'); const mockClientEncryptInfo = {
jest.mock('../services/counter.service'); _id: new ObjectId('65f29fc192862d6a9067ad28'),
jest.mock('../services/surveyResponse.service'); data: {
jest.mock('../services/clientEncrypt.service'); publicKey:
jest.mock('src/utils/checkSign'); '-----BEGIN PUBLIC KEY-----\r\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA45uWd29i6dcjLP2Cp4IV\r\naGASv+tHeaqQt8t7jojtc6rO46dD0CUkPTo9aewtuDxTHFDiKWJRJMRdXIUFNqVH\r\n1SKX7rCSG/Fh9G14pnddnSFF1eagGfvXBptycp5vKQb1IYT85zqqfORI6mGnhsQ/\r\nj+POVkIb+ANAAUXo8O/kLpVk0+cbitZYFZZWzhf+ZtSRhitlD55zonJ+Nz2hWpmr\r\npeKAG0VTRX27fDUyu2YpVFbwz7SjDsbdZ/L8XjLsUaHzRaDHL6sYYH7cWIQzj2DQ\r\nzhkR+RzeQNiSct0k7kmQ8LotWv/8sER0/yglXXH0Go42myjMI7i/2T7NpJ2ywxa3\r\nCwIDAQAB\r\n-----END PUBLIC KEY-----\r\n',
jest.mock('crypto-js/aes'); privateKey:
'-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA45uWd29i6dcjLP2Cp4IVaGASv+tHeaqQt8t7jojtc6rO46dD\r\n0CUkPTo9aewtuDxTHFDiKWJRJMRdXIUFNqVH1SKX7rCSG/Fh9G14pnddnSFF1eag\r\nGfvXBptycp5vKQb1IYT85zqqfORI6mGnhsQ/j+POVkIb+ANAAUXo8O/kLpVk0+cb\r\nitZYFZZWzhf+ZtSRhitlD55zonJ+Nz2hWpmrpeKAG0VTRX27fDUyu2YpVFbwz7Sj\r\nDsbdZ/L8XjLsUaHzRaDHL6sYYH7cWIQzj2DQzhkR+RzeQNiSct0k7kmQ8LotWv/8\r\nsER0/yglXXH0Go42myjMI7i/2T7NpJ2ywxa3CwIDAQABAoIBAEfqKhGUpRkje57E\r\nftq0VFVFPcdb7Jp5lP4tkd2IUBZi2rm9aMTEZ33c//iOwidbEBt7RuoygVbvoFwS\r\nP4JzmI20P3MQYSnpC70yNZPLVU3HbIxYMS/kjZ0t0mx6uL6qzxsHLO1WcPXDH3LG\r\n5irDqR2qqdBBVRr40+lTEHXIJj29J5NNWjGcCtv8EkqzrhHjF0XypVrGsFCdm0yB\r\n3We1ypU68JC4AFzheC4ckk7Cm90oMC8eIqn8iYb4w24NYzyqDOcHupBlljHzxT8x\r\n89cy490LKI0j06+OchlSHWgy2ixO4s1futTCA789f68+ZEhtv8gNsLpY3+iI35ni\r\n/M5+VHkCgYEA+2PUd9UNkQVAQp8UVThkEgfRs4T9DF0RXD1HpzYev4gj/KbZGCw5\r\nUlOC7ufiY7MfVPil2tC9vv/pjzyATHNP1liM97AIhB/bj5V5wOvPXEGVyey/MkBw\r\n4e2cf4xFfaJL2piE/FqJ1kDrbDN4vEC8fz0lvR9NhfEVFHgtUyp3zgcCgYEA58gc\r\nQ5z7M0n1/YzDVtMcuXzeKLP1mBavelhy8W6OgGwOoMixsobEo4Rx7EFWBXNGmc8K\r\n5yXzN7UEdrpCUNGoU0j51B7q7qf+I/bp0k09q7BEKT+bEYvYaDALVxvqKHUaAafI\r\nQUWCu7TWmymHCiWtkHnMTkcyN6baJCdAaK1Qjd0CgYB12UfqYVt5x69nS/IZPVVU\r\nSowZD1gdaqfPyP6FOc7SVT0hnQoa1eiNWo7/9n7f5EHk8Ke327GID6prNp6iuFAO\r\nGPcEymZDojeoqRcpxKIyCqDwx2aeZS1GDMEX3idZjTLoKCX3s234nfh/geWwwtxa\r\n/cxqS3lpOCp8rRX6bec6EwKBgGayhcN3lN3+0V3MtuiLldih+RVz10fSFWJSOmu7\r\nHqzMNBcNlZ6SlCIXlxqlQGYd05Rm5l/Qstll/VpV4PhKTRjJ5tgT8uhXywVIbAXg\r\nb4jZCvpz0lON8Q8I6p1oIvJWIHXHT7WMBQcCc2xAlDLsyuCO9vVgGmIKLfGC6sj2\r\nshCJAoGBAL6FK1se6TqKsBdGPMqZTL5qbHrhDBeTZFVThank6Yji80jKjouLYMTK\r\nTLsu5zSvOPiDsHjYASNs4s0Hluw7OY/i4UdhoAJ5Zqy+yjtWL1ZPZueHVSse41Ip\r\n+q5VeW6LUnVxdF20RQA/S5sbcut0NTB7pjZi7YlmwksywFZooSSz\r\n-----END RSA PRIVATE KEY-----\r\n',
},
type: 'rsa',
curStatus: {
status: 'new',
date: 1710399425273.0,
},
statusList: [
{
status: 'new',
date: 1710399425273.0,
},
],
createDate: 1710399425273.0,
updateDate: 1710399425273.0,
};
describe('SurveyResponseController', () => { describe('SurveyResponseController', () => {
let controller: SurveyResponseController; let controller: SurveyResponseController;
let responseSchemaService: ResponseSchemaService; let responseSchemaService: ResponseSchemaService;
// let counterService: CounterService;
let surveyResponseService: SurveyResponseService;
let clientEncryptService: ClientEncryptService; let clientEncryptService: ClientEncryptService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [SurveyResponseController], controllers: [SurveyResponseController],
providers: [ providers: [
ResponseSchemaService, {
CounterService, provide: ResponseSchemaService,
SurveyResponseService, useValue: {
ClientEncryptService, getResponseSchemaByPath: jest.fn(),
},
},
{
provide: CounterService,
useValue: {
get: jest.fn().mockResolvedValue(null),
set: jest.fn(),
},
},
{
provide: SurveyResponseService,
useValue: {
getSurveyResponseTotalByPath: jest.fn(),
createSurveyResponse: jest.fn(),
},
},
{
provide: ClientEncryptService,
useValue: {
deleteEncryptInfo: jest.fn(),
getEncryptInfoById: jest
.fn()
.mockResolvedValue(mockClientEncryptInfo),
},
},
PluginManagerProvider,
], ],
}).compile(); }).compile();
@ -39,68 +110,140 @@ describe('SurveyResponseController', () => {
responseSchemaService = module.get<ResponseSchemaService>( responseSchemaService = module.get<ResponseSchemaService>(
ResponseSchemaService, ResponseSchemaService,
); );
// counterService = module.get<CounterService>(CounterService);
surveyResponseService = module.get<SurveyResponseService>(
SurveyResponseService,
);
clientEncryptService = clientEncryptService =
module.get<ClientEncryptService>(ClientEncryptService); module.get<ClientEncryptService>(ClientEncryptService);
const pluginManager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager,
);
pluginManager.registerPlugin(
new ResponseSecurityPlugin('dataAesEncryptSecretKey'),
);
});
it('should be defined', () => {
expect(controller).toBeDefined();
}); });
describe('createResponse', () => { describe('createResponse', () => {
it('should create survey response successfully with valid parameters', async () => { it('should create response successfully', async () => {
const mockReqBody = { const reqBody = cloneDeep(mockSubmitData);
surveyPath: '5q1PbCtvPM',
data: '%7B%22data458%22%3A%22111%22%2C%22data515%22%3A%22xhfudsdg%22%7D', jest
difTime: 5687, .spyOn(responseSchemaService, 'getResponseSchemaByPath')
clientTime: 1706103961153, .mockResolvedValueOnce(mockResponseSchema);
encryptType: 'aes', jest
sessionId: '65b11493e8df57de0ff04c98', .spyOn(surveyResponseService, 'getSurveyResponseTotalByPath')
sign: 'c7ca1a8217a9ef0f4c4ed58701899603ce446353784a22c35774240f4cf4c5a4.1706103961154', .mockResolvedValueOnce(0);
}; jest
const mockResponseSchema = { .spyOn(surveyResponseService, 'createSurveyResponse')
curStatus: { status: RECORD_STATUS.PUBLISHED, date: Date.now() }, .mockResolvedValueOnce(undefined);
code: { jest
dataConf: { .spyOn(clientEncryptService, 'deleteEncryptInfo')
dataList: [], .mockResolvedValueOnce(undefined);
},
}, const result = await controller.createResponse(reqBody);
};
const mockClientEncryptData = { expect(result).toEqual({ code: 200, msg: '提交成功' });
expect(
responseSchemaService.getResponseSchemaByPath,
).toHaveBeenCalledWith(reqBody.surveyPath);
expect(
surveyResponseService.getSurveyResponseTotalByPath,
).toHaveBeenCalledWith(reqBody.surveyPath);
expect(surveyResponseService.createSurveyResponse).toHaveBeenCalledWith({
surveyPath: reqBody.surveyPath,
data: { data: {
secretKey: 'testSecretKey', data405: '浙江省杭州市西湖区xxx',
data450: '450111000000000000',
data458: '15000000000',
data515: '115019',
data770: '123456@qq.com',
}, },
}; clientTime: reqBody.clientTime,
difTime: reqBody.difTime,
surveyId: mockResponseSchema.pageId, // mock response schema 的 pageId
optionTextAndId: {
data515: [
{
hash: '115019',
text: '<p>男</p>',
},
{
hash: '115020',
text: '<p>女</p>',
},
],
},
});
expect(clientEncryptService.deleteEncryptInfo).toHaveBeenCalledWith(
reqBody.sessionId,
);
});
it('should throw SurveyNotFoundException if survey does not exist', async () => {
const reqBody = cloneDeep(mockSubmitData);
jest jest
.spyOn(responseSchemaService, 'getResponseSchemaByPath') .spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValue(mockResponseSchema as ResponseSchema); .mockResolvedValueOnce(null);
jest
.spyOn(clientEncryptService, 'getEncryptInfoById')
.mockResolvedValue(mockClientEncryptData as ClientEncrypt);
jest.spyOn(aes, 'decrypt').mockImplementation((data) => data); await expect(controller.createResponse(reqBody)).rejects.toThrow(
SurveyNotFoundException,
const result = await controller.createResponse(mockReqBody); );
expect(result).toEqual({
code: 200,
msg: '提交成功',
});
}); });
it('should throw SurveyNotFoundException when response schema is not found', async () => { it('should throw HttpException if no sign', async () => {
const mockReqBody = { const reqBody = cloneDeep(mockSubmitData);
surveyPath: '5q1PbCtvPM', delete reqBody.sign;
data: '%7B%22data458%22%3A%22111%22%2C%22data515%22%3A%22xhfudsdg%22%7D',
encryptType: 'validEncryptType', await expect(controller.createResponse(reqBody)).rejects.toThrow(
sessionId: 'validSessionId', HttpException,
clientTime: 123456789, );
difTime: 0,
}; expect(
responseSchemaService.getResponseSchemaByPath,
).toHaveBeenCalledTimes(0);
});
it('should throw HttpException if no sign error', async () => {
const reqBody = cloneDeep(mockDecryptErrorBody);
reqBody.sign = 'mock sign';
await expect(controller.createResponse(reqBody)).rejects.toThrow(
HttpException,
);
expect(
responseSchemaService.getResponseSchemaByPath,
).toHaveBeenCalledTimes(0);
});
it('should throw HttpException if answer time is invalid', async () => {
const reqBody = { surveyPath: 'validSurveyPath' };
jest jest
.spyOn(responseSchemaService, 'getResponseSchemaByPath') .spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValue(null); .mockResolvedValueOnce(mockResponseSchema);
await expect(controller.createResponse(mockReqBody)).rejects.toThrow( await expect(controller.createResponse(reqBody)).rejects.toThrow(
new SurveyNotFoundException('该问卷不存在,无法提交'), HttpException,
);
});
it('should throw HttpException if rsa decrypt error', async () => {
const reqBody = mockDecryptErrorBody;
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValueOnce(mockResponseSchema);
await expect(controller.createResponse(reqBody)).rejects.toThrow(
HttpException,
); );
}); });
}); });

View File

@ -0,0 +1,84 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SurveyResponseService } from '../services/surveyResponse.service';
import { MongoRepository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { SurveyResponse } from 'src/models/surveyResponse.entity';
describe('SurveyResponseService', () => {
let service: SurveyResponseService;
let surveyResponseRepository: MongoRepository<SurveyResponse>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SurveyResponseService,
{
provide: getRepositoryToken(SurveyResponse),
useValue: {
create: jest.fn(),
save: jest.fn(),
count: jest.fn(),
},
},
],
}).compile();
service = module.get<SurveyResponseService>(SurveyResponseService);
surveyResponseRepository = module.get<MongoRepository<SurveyResponse>>(
getRepositoryToken(SurveyResponse),
);
});
it('should create a survey response', async () => {
const surveyData = {
data: {},
clientTime: new Date(),
difTime: 0,
surveyId: 'testId',
surveyPath: 'testPath',
optionTextAndId: {},
};
jest
.spyOn(surveyResponseRepository, 'create')
.mockImplementation((data) => {
const surveyResponse = new SurveyResponse();
for (const key in data) {
surveyResponse[key] = data[key];
}
return surveyResponse;
});
jest
.spyOn(surveyResponseRepository, 'save')
.mockImplementation((surveyResponse: SurveyResponse) => {
return Promise.resolve(surveyResponse);
});
await service.createSurveyResponse(surveyData);
expect(surveyResponseRepository.create).toHaveBeenCalledWith({
surveyPath: surveyData.surveyPath,
data: surveyData.data,
clientTime: surveyData.clientTime,
difTime: surveyData.difTime,
pageId: surveyData.surveyId,
secretKeys: [],
optionTextAndId: surveyData.optionTextAndId,
});
});
it('should get the total survey response count by path', async () => {
const surveyPath = 'testPath';
const count = 10;
jest.spyOn(surveyResponseRepository, 'count').mockResolvedValue(count);
const result = await service.getSurveyResponseTotalByPath(surveyPath);
expect(result).toEqual(count);
expect(surveyResponseRepository.count).toHaveBeenCalledWith({
where: {
surveyPath,
'curStatus.status': { $ne: 'removed' },
},
});
});
});

View File

@ -0,0 +1,31 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SurveyResponseUIController } from '../controllers/surveyResponseUI.controller';
import { Response } from 'express';
import { join } from 'path';
describe('SurveyResponseUIController', () => {
let controller: SurveyResponseUIController;
let res: Response;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SurveyResponseUIController],
}).compile();
controller = module.get<SurveyResponseUIController>(
SurveyResponseUIController,
);
res = {
sendFile: jest.fn().mockResolvedValue(undefined),
} as unknown as Response;
});
it('should render the survey response with the correct path', () => {
const surveyPath = 'some-survey-path';
const expectedFilePath = join(process.cwd(), 'public', 'render.html');
controller.render(surveyPath, res);
expect(res.sendFile).toHaveBeenCalledWith(expectedFilePath);
});
});

View File

@ -144,7 +144,6 @@ export class SurveyResponseController {
return pre; return pre;
}, {}); }, {});
const secretKeys = [];
// 对用户提交的数据进行遍历处理 // 对用户提交的数据进行遍历处理
for (const field in decryptedData) { for (const field in decryptedData) {
const value = decryptedData[field]; const value = decryptedData[field];
@ -180,7 +179,6 @@ export class SurveyResponseController {
data: decryptedData, data: decryptedData,
clientTime, clientTime,
difTime, difTime,
secretKeys,
surveyId: responseSchema.pageId, surveyId: responseSchema.pageId,
optionTextAndId, optionTextAndId,
}); });

View File

@ -11,7 +11,6 @@ export class SurveyResponseService {
async createSurveyResponse({ async createSurveyResponse({
data, data,
secretKeys,
clientTime, clientTime,
difTime, difTime,
surveyId, surveyId,
@ -21,7 +20,7 @@ export class SurveyResponseService {
const newSubmitData = this.surveyResponseRepository.create({ const newSubmitData = this.surveyResponseRepository.create({
surveyPath, surveyPath,
data, data,
secretKeys, secretKeys: [],
clientTime, clientTime,
difTime, difTime,
pageId: surveyId, pageId: surveyId,

View File

@ -0,0 +1,5 @@
import { createHash } from 'crypto';
export function hash256(text) {
return createHash('sha256').update(text).digest('hex');
}

View File

@ -1,10 +0,0 @@
import { customAlphabet } from 'nanoid';
const surveyPathAlphabet =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
export function genSurveyPath({ size, prefix } = { size: 8, prefix: '' }) {
size = Number(size) ? Number(size) : 8;
const id = customAlphabet(`${prefix || ''}${surveyPathAlphabet}`, size);
return id();
}

View File

@ -0,0 +1,73 @@
export type FilterItem = {
comparator?: string;
condition: Array<FilterCondition>;
};
export type FilterCondition = {
field: string;
comparator?: string;
value: string & Array<FilterItem>;
};
export type OrderItem = {
field: string;
value: number;
};
export function getFilter(filterList: Array<FilterItem>) {
const allowFilterField = [
'title',
'remark',
'surveyType',
'curStatus.status',
];
return filterList.reduce(
(preItem, curItem) => {
const condition = curItem.condition
.filter((item) => allowFilterField.includes(item.field))
.reduce((pre, cur) => {
switch (cur.comparator) {
case '$ne':
pre[cur.field] = {
$ne: cur.value,
};
break;
case '$regex':
pre[cur.field] = {
$regex: cur.value,
};
break;
default:
pre[cur.field] = cur.value;
break;
}
return pre;
}, {});
switch (curItem.comparator) {
case '$or':
if (!Array.isArray(preItem.$or)) {
preItem.$or = [];
}
preItem.$or.push(condition);
break;
default:
Object.assign(preItem, condition);
break;
}
return preItem;
},
{} as { $or?: Array<Record<string, string>> } & Record<string, string>,
);
}
export function getOrder(order: Array<OrderItem>) {
const allowOrderFields = ['createDate', 'updateDate', 'curStatus.date'];
const orderList = order.filter((orderItem) =>
allowOrderFields.includes(orderItem.field),
);
return orderList.reduce((pre, cur) => {
pre[cur.field] = cur.value === 1 ? 1 : -1;
return pre;
}, {});
}