diff --git a/src/modules/questions/entities/question-transition.entity.ts b/src/modules/questions/entities/question-transition.entity.ts index 8996e69..65c6956 100644 --- a/src/modules/questions/entities/question-transition.entity.ts +++ b/src/modules/questions/entities/question-transition.entity.ts @@ -9,7 +9,7 @@ export class QuestionTransition { @PrimaryGeneratedColumn() id!: number; - @ManyToOne(() => Test, { onDelete: 'CASCADE' }) + @ManyToOne(() => Test, (test) => test.transitions, { onDelete: 'CASCADE' }) test!: Test; @ManyToOne(() => Question, { nullable: true, onDelete: 'CASCADE' }) diff --git a/src/modules/tests/entities/test.entity.ts b/src/modules/tests/entities/test.entity.ts index 14845e4..41e0ab4 100644 --- a/src/modules/tests/entities/test.entity.ts +++ b/src/modules/tests/entities/test.entity.ts @@ -10,6 +10,7 @@ import { AccessType } from '../enums/access-types.enum'; import { Question } from '@/modules/questions/entities/question.entity'; import { ResultRange } from '@/modules/scoring/entities/result-range.entity'; import { TestStatus } from '../enums/test-status.enum'; +import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity'; @Entity() @Check(` @@ -70,6 +71,9 @@ export class Test { @OneToMany(() => ResultRange, (range) => range.test) resultRanges!: Array; + @OneToMany(() => QuestionTransition, (transition) => transition.test) + transitions!: Array; + @CreateDateColumn() createdAt!: Date; } diff --git a/src/modules/tests/providers/publish-test-validator.provider.ts b/src/modules/tests/providers/publish-test-validator.provider.ts new file mode 100644 index 0000000..847adc1 --- /dev/null +++ b/src/modules/tests/providers/publish-test-validator.provider.ts @@ -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(); + private readonly memo = new Map(); + + constructor( + /** + * Inject Tests Repository + */ + @InjectRepository(Test) + private readonly testsRepository: Repository, + ) {} + + 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(); + 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(); + const visitedCycle = new Set(); + + // 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; + } +}