diff --git a/apps/backend/apps/client/src/submission/submission.service.spec.ts b/apps/backend/apps/client/src/submission/submission.service.spec.ts index d0e3f465e0..278382c01e 100644 --- a/apps/backend/apps/client/src/submission/submission.service.spec.ts +++ b/apps/backend/apps/client/src/submission/submission.service.spec.ts @@ -3,7 +3,13 @@ import { NotFoundException } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { Test, type TestingModule } from '@nestjs/testing' import { AmqpConnection } from '@golevelup/nestjs-rabbitmq' -import { Language, ResultStatus, Role, type User } from '@prisma/client' +import { + Language, + ResultStatus, + Role, + type Contest, + type User +} from '@prisma/client' import { expect } from 'chai' import { plainToInstance } from 'class-transformer' import { TraceService } from 'nestjs-otel' @@ -38,6 +44,9 @@ const db = { findUnique: stub(), update: stub() }, + contest: { + findFirstOrThrow: stub() + }, contestProblem: { findUniqueOrThrow: stub(), findFirstOrThrow: stub() @@ -46,7 +55,8 @@ const db = { findUniqueOrThrow: stub() }, contestRecord: { - findUniqueOrThrow: stub() + findUniqueOrThrow: stub(), + update: stub() }, user: { findFirstOrThrow: stub(), @@ -57,6 +67,21 @@ const db = { const CONTEST_ID = 1 const WORKBOOK_ID = 1 +const mockContest: Contest = { + id: CONTEST_ID, + createdById: 1, + groupId: 1, + title: 'SKKU Coding Platform 모의대회', + description: 'test', + invitationCode: 'test', + startTime: new Date(), + endTime: new Date(), + isVisible: true, + isRankVisible: true, + enableCopyPaste: true, + createTime: new Date(), + updateTime: new Date() +} describe('SubmissionService', () => { let service: SubmissionService @@ -121,6 +146,7 @@ describe('SubmissionService', () => { describe('submitToContest', () => { it('should call createSubmission', async () => { const createSpy = stub(service, 'createSubmission') + db.contest.findFirstOrThrow(mockContest) db.contestRecord.findUniqueOrThrow.resolves({ contest: { groupId: 1, @@ -216,6 +242,60 @@ describe('SubmissionService', () => { expect(publishSpy.calledOnce).to.be.true }) + it('should create submission with contestId', async () => { + const publishSpy = stub(amqpConnection, 'publish') + db.problem.findUnique.resolves(problems[0]) + db.submission.create.resolves({ + ...submissions[0], + contestId: CONTEST_ID + }) + db.submission.findMany.resolves(submissions) + expect( + await service.createSubmission( + submissionDto, + problems[0], + submissions[0].userId, + { contestId: CONTEST_ID } + ) + ).to.be.deep.equal({ ...submissions[0], contestId: CONTEST_ID }) + expect(publishSpy.calledOnce).to.be.true + }) + + it('should throw conflict found exception if user has already gotten AC', async () => { + const publishSpy = stub(amqpConnection, 'publish') + db.problem.findUnique.resolves(problems[0]) + db.submission.create.resolves(submissions[0]) + db.submission.findMany.resolves([{ result: ResultStatus.Accepted }]) + + await expect( + service.createSubmission( + submissionDto, + problems[0], + submissions[0].userId, + { contestId: CONTEST_ID } + ) + ).to.be.rejectedWith(ConflictFoundException) + expect(publishSpy.calledOnce).to.be.false + }) + + it('should create submission with workbookId', async () => { + const publishSpy = stub(amqpConnection, 'publish') + db.problem.findUnique.resolves(problems[0]) + db.submission.create.resolves({ + ...submissions[0], + workbookId: WORKBOOK_ID + }) + expect( + await service.createSubmission( + submissionDto, + problems[0], + submissions[0].userId, + { workbookId: WORKBOOK_ID } + ) + ).to.be.deep.equal({ ...submissions[0], workbookId: WORKBOOK_ID }) + expect(publishSpy.calledOnce).to.be.true + }) + it('should throw exception if the language is not supported', async () => { const publishSpy = stub(amqpConnection, 'publish') db.problem.findUnique.resolves(problems[0]) @@ -280,19 +360,24 @@ describe('SubmissionService', () => { ] } } - + db.submission.update.resolves(submissions[0]) await expect(service.handleJudgerMessage(target)).to.be.rejectedWith( UnprocessableDataException ) + db.submission.update.reset() }) }) describe('updateSubmissionResult', () => { it('should call update submission result', async () => { db.submission.update.reset() - db.submission.update.resolves(submissions[0]) + db.submission.update.resolves({ + ...submissions[0], + contestId: CONTEST_ID + }) db.problem.findFirstOrThrow.resolves(problems[0]) db.problem.update.reset() + submissionResults.forEach((result, index) => { db.submissionResult.create.onCall(index).resolves(result) }) diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index 8568d57f56..0c88ba6909 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -111,7 +111,18 @@ export class SubmissionService implements OnModuleInit { groupId = OPEN_SPACE_ID ) { const now = new Date() - + await this.prisma.contest.findFirstOrThrow({ + where: { + id: contestId, + groupId, + startTime: { + lte: now + }, + endTime: { + gt: now + } + } + }) const { contest } = await this.prisma.contestRecord.findUniqueOrThrow({ where: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -192,8 +203,6 @@ export class SubmissionService implements OnModuleInit { userId: number, idOptions?: { contestId?: number; workbookId?: number } ) { - let submission: Submission - if (!problem.languages.includes(submissionDto.language)) { throw new ConflictFoundException( `This problem does not support language ${submissionDto.language}` @@ -219,23 +228,45 @@ export class SubmissionService implements OnModuleInit { ...data } - if (idOptions && idOptions.contestId) { - submission = await this.prisma.submission.create({ - data: { ...submissionData, contestId: idOptions.contestId } + // idOptions Object가 undefined이거나 contestId와 workbookId가 모두 없는 경우 + if ( + idOptions === undefined || + (!idOptions.contestId && !idOptions.workbookId) + ) { + const submission = await this.prisma.submission.create({ + data: submissionData }) - } else if (idOptions && idOptions.workbookId) { - submission = await this.prisma.submission.create({ - data: { ...submissionData, workbookId: idOptions.workbookId } + await this.publishJudgeRequestMessage(code, submission) + return submission + } + + if (idOptions.contestId) { + // 해당 contestId에 해당하는 Contest에서 해당 problemId에 해당하는 문제로 AC를 받은 submission이 있는지 확인 + const hasPassed = await this.hasPassedProblem(userId, { + problemId: problem.id, + contestId: idOptions.contestId }) - } else { - submission = await this.prisma.submission.create({ - data: submissionData + if (hasPassed) { + throw new ConflictFoundException( + 'You have already gotten AC for this problem' + ) + } + const submission = await this.prisma.submission.create({ + data: { ...submissionData, contestId: idOptions.contestId } }) + + await this.publishJudgeRequestMessage(code, submission) + return submission } - await this.publishJudgeRequestMessage(code, submission) + if (idOptions.workbookId) { + const submission = await this.prisma.submission.create({ + data: { ...submissionData, workbookId: idOptions.workbookId } + }) - return submission + await this.publishJudgeRequestMessage(code, submission) + return submission + } } isValidCode(code: Snippet[], language: Language, templates: Template[]) { @@ -439,6 +470,66 @@ export class SubmissionService implements OnModuleInit { } }) } + + // contestId가 있는 경우에는 contestRecord 업데이트 + // participants들의 score와 penalty를 업데이트 + if (submission.userId && submission.contestId) { + const contestId = submission.contestId + const userId = submission.userId + let toBeAddedScore = 0, + toBeAddedPenalty = 0, + toBeAddedAcceptedProblemNum = 0, + isFinishTimeToBeUpdated = false + const contestRecord = await this.prisma.contestRecord.findUniqueOrThrow({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestId_userId: { + contestId, + userId + } + }, + select: { + id: true, + acceptedProblemNum: true, + score: true, + totalPenalty: true, + finishTime: true + } + }) + + if (resultStatus === ResultStatus.Accepted) { + toBeAddedScore = ( + await this.prisma.contestProblem.findFirstOrThrow({ + where: { + contestId, + problemId: submission.problemId + }, + select: { + score: true + } + }) + ).score + isFinishTimeToBeUpdated = true + toBeAddedAcceptedProblemNum = 1 + } else { + toBeAddedPenalty = 1 + } + + await this.prisma.contestRecord.update({ + where: { + id: contestRecord.id + }, + data: { + acceptedProblemNum: + contestRecord.acceptedProblemNum + toBeAddedAcceptedProblemNum, + score: contestRecord.score + toBeAddedScore, + totalPenalty: contestRecord.totalPenalty + toBeAddedPenalty, + finishTime: isFinishTimeToBeUpdated + ? submission.updateTime + : contestRecord.finishTime + } + }) + } } // FIXME: Workbook 구분 diff --git a/apps/backend/prisma/migrations/20240408084124_add_finish_time_on_contest_record/migration.sql b/apps/backend/prisma/migrations/20240408084124_add_finish_time_on_contest_record/migration.sql new file mode 100644 index 0000000000..a7661dd980 --- /dev/null +++ b/apps/backend/prisma/migrations/20240408084124_add_finish_time_on_contest_record/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "contest_record" ADD COLUMN "finish_time" TIMESTAMP(3); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 8443e3841c..3fba1c0ca7 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -297,6 +297,7 @@ model ContestProblem { contestId Int @map("contest_id") problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) problemId Int @map("problem_id") + // 각 문제의 점수 (비율 아님) score Int @default(0) createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") @@ -321,16 +322,19 @@ model Announcement { } model ContestRecord { - id Int @id @default(autoincrement()) - contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade) - contestId Int @map("contest_id") - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - userId Int? @map("user_id") - acceptedProblemNum Int @default(0) @map("accepted_problem_num") - score Int @default(0) - totalPenalty Int @default(0) @map("total_penalty") - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") + id Int @id @default(autoincrement()) + contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade) + contestId Int @map("contest_id") + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + userId Int? @map("user_id") + acceptedProblemNum Int @default(0) @map("accepted_problem_num") + score Int @default(0) + // finishTime: Pariticipant가 가장 최근에 AC를 받은 시각 + finishTime DateTime? @map("finish_time") + // totalPenalty: Submission 시, AC를 받지 못했을 때, 올라가는 Counter + totalPenalty Int @default(0) @map("total_penalty") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") @@unique([contestId, userId]) @@map("contest_record") diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 4edb19eb92..75bf8da8f9 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -1301,7 +1301,8 @@ const createContests = async () => { data: { order: problem.id - 1, contestId: ongoingContests[0].id, - problemId: problem.id + problemId: problem.id, + score: problem.id * 10 } }) } @@ -1700,7 +1701,6 @@ const createCodeDrafts = async () => { const createContestRecords = async () => { const contestRecords: ContestRecord[] = [] - let i = 0 // group 1 users const group1Users = await prisma.userGroup.findMany({ where: { @@ -1712,12 +1712,11 @@ const createContestRecords = async () => { data: { userId: user.userId, contestId: 1, - acceptedProblemNum: user.userId, - totalPenalty: i * 60 + acceptedProblemNum: 0, + totalPenalty: 0 } }) contestRecords.push(contestRecord) - i++ } // upcoming contest에 참가한 User 1의 contest register를 un-register하는 기능과, diff --git a/collection/client/Submission/Create Submission/404: No Contest found.bru b/collection/client/Submission/Create Submission/404: No Contest found.bru new file mode 100644 index 0000000000..cdd51d0843 --- /dev/null +++ b/collection/client/Submission/Create Submission/404: No Contest found.bru @@ -0,0 +1,59 @@ +meta { + name: 404: No Contest found + type: http + seq: 5 +} + +post { + url: {{baseUrl}}/submission?problemId=1&contestId=999 + body: json + auth: none +} + +params:query { + problemId: 1 + contestId: 999 + ~groupId: 1 + ~workbookId: 1 +} + +body:json { + { + "code": [ + { + "id": 1, + "text": "#include \nint main() { int a, b; scanf(\"%d%d\", &a, &b); printf(\"%d\\n\", a - b);}", + "locked": false + } + ], + "language": "C" + } +} + +assert { + res.body.message: eq "No Contest found" + res.body.error: eq "Not Found" + res.body.statusCode: eq 404 +} + +script:pre-request { + await require("./login").loginUser(req); +} + +docs { + ## 404: No Contest found + + 존재하지 `Contest`에 Submission을 제출합니다. + + ### Query + + > 필수 query는 * 표시하였습니다. + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |problemId *|Integer|문제 ID| + |groupId|Integer|문제가 속한 Group ID (default: 1)| + |contestId|Integer|문제가 속한 대회 ID| + |workbookId|Integer|문제가 속한 문제집 ID| + +} diff --git a/collection/client/Submission/Create Submission/Succeed.bru b/collection/client/Submission/Create Submission/Succeed.bru index ef8c516a8a..bdfc9450c8 100644 --- a/collection/client/Submission/Create Submission/Succeed.bru +++ b/collection/client/Submission/Create Submission/Succeed.bru @@ -10,10 +10,10 @@ post { auth: none } -query { +params:query { problemId: 1 - ~groupId: 1 ~contestId: 1 + ~groupId: 1 ~workbookId: 1 } diff --git a/collection/client/Submission/Create Submission/Succeed: Contest(AC).bru b/collection/client/Submission/Create Submission/Succeed: Contest(AC).bru new file mode 100644 index 0000000000..9d919a1a8e --- /dev/null +++ b/collection/client/Submission/Create Submission/Succeed: Contest(AC).bru @@ -0,0 +1,73 @@ +meta { + name: Succeed: Contest(AC) + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/submission?problemId=1&contestId=1 + body: json + auth: none +} + +params:query { + problemId: 1 + contestId: 1 + ~groupId: 1 + ~workbookId: 1 +} + +body:json { + { + "code": [ + { + "id": 1, + "text": "#include \nint main() { int a, b; scanf(\"%d%d\", &a, &b); printf(\"%d\\n\", a + b);}", + "locked": false + } + ], + "language": "C" + } +} + +assert { + res.body.id: isNumber + res.body.userId: isNumber + res.body.problemId: isNumber + res.body.contestId: isNumber + res.body.workbookId: isNull + res.body.code: isArray + res.body.code[0]: isJson + res.body.codeSize: isNumber + res.body.language: isString + res.body.result: isString + res.body.createTime: isString + res.body.updateTime: isString +} + +script:pre-request { + await require("./login").loginUser(req); +} + +docs { + ## Create Submission (AC) + + `Contest`에 AC 판정을 받는 코드를 제출하여, 채점 요청을 보냅니다. + + ### Query + + > 필수 query는 * 표시하였습니다. + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |problemId *|Integer|문제 ID| + |groupId|Integer|문제가 속한 Group ID (default: 1)| + |contestId|Integer|문제가 속한 대회 ID| + |workbookId|Integer|문제가 속한 문제집 ID| + + ### Error Case + + #### 404: No Contest found + + 현재 진행 중이지 않는 `Contest`에 `Submission`을 Create하려는 경우 +} diff --git a/collection/client/Submission/Create Submission/Succeed: Contest(TLE).bru b/collection/client/Submission/Create Submission/Succeed: Contest(TLE).bru new file mode 100644 index 0000000000..f54308f94a --- /dev/null +++ b/collection/client/Submission/Create Submission/Succeed: Contest(TLE).bru @@ -0,0 +1,74 @@ +meta { + name: Succeed: Contest(TLE) + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/submission?problemId=1&contestId=1 + body: json + auth: none +} + +params:query { + problemId: 1 + contestId: 1 + ~groupId: 1 + ~workbookId: 1 +} + +body:json { + { + "code": [ + { + "id": 1, + "text": "#include \nint main() { int a, b; scanf(\"%d%d\", &a, &b); while(1);}", + "locked": false + } + ], + "language": "C" + } +} + +assert { + res.body.id: isNumber + res.body.userId: isNumber + res.body.problemId: isNumber + res.body.contestId: isNumber + res.body.workbookId: isNull + res.body.code: isArray + res.body.code[0]: isJson + res.body.codeSize: isNumber + res.body.language: isString + res.body.result: isString + res.body.createTime: isString + res.body.updateTime: isString +} + +script:pre-request { + await require("./login").loginUser(req); +} + +docs { + ## Create Submission (TLE) + + `Contest`에 TLE 판정을 받는 코드를 제출하여, 채점 요청을 보냅니다. + + ### Query + + > 필수 query는 * 표시하였습니다. + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |problemId *|Integer|문제 ID| + |groupId|Integer|문제가 속한 Group ID (default: 1)| + |contestId|Integer|문제가 속한 대회 ID| + |workbookId|Integer|문제가 속한 문제집 ID| + + ### Error Case + + #### 404: No Contest found + + 현재 진행 중이지 않는 `Contest`에 `Submission`을 Create하려는 경우 + +} diff --git a/collection/client/Submission/Create Submission/Succeed: Contest(WA).bru b/collection/client/Submission/Create Submission/Succeed: Contest(WA).bru new file mode 100644 index 0000000000..432dd3d4fa --- /dev/null +++ b/collection/client/Submission/Create Submission/Succeed: Contest(WA).bru @@ -0,0 +1,68 @@ +meta { + name: Succeed: Contest(WA) + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/submission?problemId=1&contestId=1 + body: json + auth: none +} + +params:query { + problemId: 1 + contestId: 1 + ~groupId: 1 + ~workbookId: 1 +} + +body:json { + { + "code": [ + { + "id": 1, + "text": "#include \nint main() { int a, b; scanf(\"%d%d\", &a, &b); printf(\"%d\\n\", a - b);}", + "locked": false + } + ], + "language": "C" + } +} + +assert { + res.body.id: isNumber + res.body.userId: isNumber + res.body.problemId: isNumber + res.body.contestId: isNumber + res.body.workbookId: isNull + res.body.code: isArray + res.body.code[0]: isJson + res.body.codeSize: isNumber + res.body.language: isString + res.body.result: isString + res.body.createTime: isString + res.body.updateTime: isString +} + +script:pre-request { + await require("./login").loginUser(req); +} + +docs { + ## Create Submission (WA) + + `Contest`에 WA 판정을 받는 코드를 제출하여, 채점 요청을 보냅니다. + + ### Query + + > 필수 query는 * 표시하였습니다. + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |problemId *|Integer|문제 ID| + |groupId|Integer|문제가 속한 Group ID (default: 1)| + |contestId|Integer|문제가 속한 대회 ID| + |workbookId|Integer|문제가 속한 문제집 ID| + +}