feat: add test publish validator
This commit is contained in:
@@ -9,7 +9,7 @@ export class QuestionTransition {
|
|||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
@ManyToOne(() => Test, { onDelete: 'CASCADE' })
|
@ManyToOne(() => Test, (test) => test.transitions, { onDelete: 'CASCADE' })
|
||||||
test!: Test;
|
test!: Test;
|
||||||
|
|
||||||
@ManyToOne(() => Question, { nullable: true, onDelete: 'CASCADE' })
|
@ManyToOne(() => Question, { nullable: true, onDelete: 'CASCADE' })
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AccessType } from '../enums/access-types.enum';
|
|||||||
import { Question } from '@/modules/questions/entities/question.entity';
|
import { Question } from '@/modules/questions/entities/question.entity';
|
||||||
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
||||||
import { TestStatus } from '../enums/test-status.enum';
|
import { TestStatus } from '../enums/test-status.enum';
|
||||||
|
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Check(`
|
@Check(`
|
||||||
@@ -70,6 +71,9 @@ export class Test {
|
|||||||
@OneToMany(() => ResultRange, (range) => range.test)
|
@OneToMany(() => ResultRange, (range) => range.test)
|
||||||
resultRanges!: Array<ResultRange>;
|
resultRanges!: Array<ResultRange>;
|
||||||
|
|
||||||
|
@OneToMany(() => QuestionTransition, (transition) => transition.test)
|
||||||
|
transitions!: Array<QuestionTransition>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Test } from '../entities/test.entity';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { AppException } from '@/common/exceptions/app.exception';
|
||||||
|
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||||
|
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
|
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PublishTestValidatorProvider {
|
||||||
|
private readonly graph = new Map<number, QuestionTransition[]>();
|
||||||
|
private readonly memo = new Map<number, boolean>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/**
|
||||||
|
* Inject Tests Repository
|
||||||
|
*/
|
||||||
|
@InjectRepository(Test)
|
||||||
|
private readonly testsRepository: Repository<Test>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async validate(testId: number) {
|
||||||
|
let test: Test | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
test = await this.testsRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: testId,
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
transitions: {
|
||||||
|
fromQuestion: true,
|
||||||
|
toQuestion: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!test) {
|
||||||
|
throw new AppException(
|
||||||
|
'common.errors.entityNotFound',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
{ entity: '$t(tests.fields.test)' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitions = test.transitions;
|
||||||
|
|
||||||
|
const startTransitions = transitions.filter(
|
||||||
|
(t) => t.type === QuestionTransitionType.START,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (startTransitions.length !== 1) {
|
||||||
|
throw new AppException(
|
||||||
|
'tests.errors.mustHaveSingleStart',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startQuestionId = startTransitions[0].toQuestion!.id;
|
||||||
|
|
||||||
|
for (const transition of transitions) {
|
||||||
|
if (!transition.fromQuestion) continue;
|
||||||
|
|
||||||
|
const list = this.graph.get(transition.fromQuestion.id) ?? [];
|
||||||
|
|
||||||
|
list.push(transition);
|
||||||
|
|
||||||
|
this.graph.set(transition.fromQuestion.id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set<number>();
|
||||||
|
const stack = [startQuestionId];
|
||||||
|
|
||||||
|
while (stack.length) {
|
||||||
|
const id = stack.pop();
|
||||||
|
if (!id) break;
|
||||||
|
|
||||||
|
if (visited.has(id)) continue;
|
||||||
|
|
||||||
|
visited.add(id);
|
||||||
|
|
||||||
|
const next = this.graph.get(id) ?? [];
|
||||||
|
|
||||||
|
for (const transition of next) {
|
||||||
|
const toQuestion = transition.toQuestion;
|
||||||
|
|
||||||
|
if (toQuestion) {
|
||||||
|
stack.push(toQuestion.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orphanQuestions = test.questions.filter((q) => !visited.has(q.id));
|
||||||
|
|
||||||
|
if (orphanQuestions.length > 0) {
|
||||||
|
throw new AppException(
|
||||||
|
'questions.errors.test.hasOrphanQuestions',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.questionReachesEnd(startQuestionId)) {
|
||||||
|
throw new AppException(
|
||||||
|
'questions.errors.test.noPathToEnd',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visiting = new Set<number>();
|
||||||
|
const visitedCycle = new Set<number>();
|
||||||
|
|
||||||
|
// TODO: Fix this
|
||||||
|
const hasCycle = (id: number): boolean => {
|
||||||
|
if (visiting.has(id)) return true;
|
||||||
|
if (visitedCycle.has(id)) return false;
|
||||||
|
|
||||||
|
visiting.add(id);
|
||||||
|
|
||||||
|
for (const transition of this.graph.get(id) ?? []) {
|
||||||
|
if (transition.toQuestion) {
|
||||||
|
if (hasCycle(transition.toQuestion.id)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.delete(id);
|
||||||
|
visitedCycle.add(id);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasCycle(startQuestionId)) {
|
||||||
|
throw new AppException(
|
||||||
|
'questions.errors.test.hasCycle',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private questionReachesEnd(questionId: number) {
|
||||||
|
if (this.memo.has(questionId)) return this.memo.get(questionId)!;
|
||||||
|
|
||||||
|
const outgoing = this.graph.get(questionId) ?? [];
|
||||||
|
|
||||||
|
for (const transition of outgoing) {
|
||||||
|
if (transition.type === QuestionTransitionType.END) {
|
||||||
|
this.memo.set(questionId, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
transition.toQuestion &&
|
||||||
|
this.questionReachesEnd(transition.toQuestion.id)
|
||||||
|
) {
|
||||||
|
this.memo.set(questionId, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.memo.set(questionId, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user