feat: add test publish validator

This commit is contained in:
2026-08-05 16:49:41 +03:30
parent 0f9543aaf2
commit 9742616f28
3 changed files with 171 additions and 1 deletions
@@ -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;
}
}