diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/BotalkaApplication.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/BotalkaApplication.kt index 43b1c21..dcff590 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/BotalkaApplication.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/BotalkaApplication.kt @@ -3,9 +3,14 @@ package ru.vityaman.lms.botalka import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.jooq.JooqAutoConfiguration import org.springframework.boot.runApplication +import org.springframework.context.annotation.Bean +import java.time.Clock @SpringBootApplication(exclude = [JooqAutoConfiguration::class]) -class BotalkaApplication +class BotalkaApplication { + @Bean + fun clock() = Clock.systemUTC() +} fun main(args: Array) { runApplication(args = args) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/error/DomainExceptionCodes.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/error/DomainExceptionCodes.kt index cc096dc..b079b55 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/error/DomainExceptionCodes.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/error/DomainExceptionCodes.kt @@ -3,6 +3,7 @@ package ru.vityaman.lms.botalka.api.http.error import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import ru.vityaman.lms.botalka.api.http.server.GeneralErrorMessage +import ru.vityaman.lms.botalka.domain.exception.DeadlinePassedException import ru.vityaman.lms.botalka.domain.exception.DomainException import ru.vityaman.lms.botalka.domain.exception.InvalidValueException import ru.vityaman.lms.botalka.domain.exception.NotFoundException @@ -13,12 +14,10 @@ val DomainException.httpCode: HttpStatus is InvalidValueException -> HttpStatus.BAD_REQUEST is NotFoundException -> HttpStatus.NOT_FOUND is PromotionRequestResolvedException -> HttpStatus.CONFLICT + is DeadlinePassedException -> HttpStatus.BAD_REQUEST else -> TODO() } -val HttpStatus.reason: String - get() = this.reasonPhrase - fun DomainException.toResponseEntity() = ResponseEntity .status(this.httpCode) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/HomeworkMapping.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/HomeworkMapping.kt index c45d2e3..4423a5f 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/HomeworkMapping.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/HomeworkMapping.kt @@ -12,6 +12,7 @@ fun Homework.toMessage(): HomeworkMessage = maxScore = this.maxScore.value.toInt(), publicationMoment = this.publicationMoment, creationMoment = this.creationMoment, + deadlineMoment = this.deadlineMoment, ) fun Homework.Draft.toMessage(): HomeworkDraftMessage = @@ -20,6 +21,7 @@ fun Homework.Draft.toMessage(): HomeworkDraftMessage = description = this.description.text, maxScore = this.maxScore.value.toInt(), publicationMoment = this.publicationMoment, + deadlineMoment = this.deadlineMoment, ) fun HomeworkDraftMessage.toModel(): Homework.Draft = @@ -28,4 +30,5 @@ fun HomeworkDraftMessage.toModel(): Homework.Draft = description = Homework.Description(this.description), maxScore = Homework.Score(this.maxScore.toShort()), publicationMoment = this.publicationMoment, + deadlineMoment = this.deadlineMoment, ) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/domain/exception/DeadlinePassedException.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/domain/exception/DeadlinePassedException.kt new file mode 100644 index 0000000..e184aa4 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/domain/exception/DeadlinePassedException.kt @@ -0,0 +1,4 @@ +package ru.vityaman.lms.botalka.domain.exception + +class DeadlinePassedException(message: String) : + DomainException(message) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/domain/model/Homework.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/domain/model/Homework.kt index 493529a..5312d3e 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/domain/model/Homework.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/domain/model/Homework.kt @@ -11,6 +11,7 @@ data class Homework( val description: Description, val maxScore: Score, val publicationMoment: OffsetDateTime, + val deadlineMoment: OffsetDateTime, val creationMoment: OffsetDateTime, ) { @JvmInline @@ -66,5 +67,12 @@ data class Homework( val description: Description, val maxScore: Score, val publicationMoment: OffsetDateTime, - ) + val deadlineMoment: OffsetDateTime, + ) { + init { + expect(publicationMoment.plusMinutes(1).isBefore(deadlineMoment)) { + append("Homework must be published 1 minute before deadline") + } + } + } } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/HomeworkService.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/HomeworkService.kt index 9fd9b1d..fc32067 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/HomeworkService.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/HomeworkService.kt @@ -4,4 +4,5 @@ import ru.vityaman.lms.botalka.domain.model.Homework interface HomeworkService { suspend fun create(homework: Homework.Draft): Homework + suspend fun getById(id: Homework.Id): Homework? } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicHomeworkService.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicHomeworkService.kt index 5d228e1..e6c1279 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicHomeworkService.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicHomeworkService.kt @@ -12,4 +12,7 @@ class BasicHomeworkService( ) : HomeworkService { override suspend fun create(homework: Homework.Draft): Homework = storage.create(homework) + + override suspend fun getById(id: Homework.Id): Homework? = + storage.getById(id) } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicWorkspaceService.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicWorkspaceService.kt index 7208e57..31d3b18 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicWorkspaceService.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicWorkspaceService.kt @@ -6,14 +6,21 @@ import kotlinx.coroutines.flow.lastOrNull import kotlinx.coroutines.flow.map import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service +import ru.vityaman.lms.botalka.domain.exception.DeadlinePassedException +import ru.vityaman.lms.botalka.domain.exception.orNotFound import ru.vityaman.lms.botalka.domain.model.Homework import ru.vityaman.lms.botalka.domain.model.Workspace +import ru.vityaman.lms.botalka.logic.HomeworkService import ru.vityaman.lms.botalka.logic.WorkspaceService import ru.vityaman.lms.botalka.storage.WorkspaceStorage +import java.time.Clock +import java.time.OffsetDateTime @Service class BasicWorkspaceService( @Autowired private val storage: WorkspaceStorage, + @Autowired private val homeworks: HomeworkService, + @Autowired private val clock: Clock, ) : WorkspaceService { override fun events(id: Workspace.Id): Flow = storage.events(id) @@ -24,7 +31,7 @@ class BasicWorkspaceService( ): Workspace.Event = when (event) { is Workspace.Comment.Draft -> storage.save(id, event) - is Workspace.Submission.Draft -> storage.save(id, event) + is Workspace.Submission.Draft -> submit(id, event) is Workspace.Feedback.Draft -> storage.save(id, event) } @@ -38,4 +45,22 @@ class BasicWorkspaceService( override fun all(): Flow = storage.all() + + private suspend fun submit( + id: Workspace.Id, + event: Workspace.Submission.Draft, + ): Workspace.Submission { + val homework = homeworks.getById(id.homework) + .orNotFound("Homework with id ${id.homework.number} not found") + if (homework.deadlineMoment.isBefore(OffsetDateTime.now(clock))) { + throw DeadlinePassedException( + buildString { + append("Deadline for homework with ") + append("id ${homework.id.number} ") + append("\"${homework.title}\" was passed") + }, + ) + } + return storage.save(id, event) + } } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/HomeworkStorage.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/HomeworkStorage.kt index d34f53b..22ad77c 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/HomeworkStorage.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/HomeworkStorage.kt @@ -4,4 +4,5 @@ import ru.vityaman.lms.botalka.domain.model.Homework interface HomeworkStorage { suspend fun create(homework: Homework.Draft): Homework + suspend fun getById(id: Homework.Id): Homework? } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqHomeworkStorage.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqHomeworkStorage.kt index 3155966..06dc68d 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqHomeworkStorage.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqHomeworkStorage.kt @@ -1,6 +1,7 @@ package ru.vityaman.lms.botalka.storage.jooq import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository import ru.vityaman.lms.botalka.domain.model.Homework @@ -20,16 +21,25 @@ class JooqHomeworkStorage( HOMEWORK.DESCRIPTION, HOMEWORK.MAX_SCORE, HOMEWORK.PUBLICATION_MOMENT, + HOMEWORK.DEADLINE_MOMENT, ) .values( homework.title.text, homework.description.text, homework.maxScore.value, homework.publicationMoment, + homework.deadlineMoment, ) .returningResult(HOMEWORK.fields().asList()) .coerce(HOMEWORK) .toMono() .map { it.toModel() } .awaitSingle() + + override suspend fun getById(id: Homework.Id): Homework? = + database.withDSLMono { + selectFrom(HOMEWORK) + .where(HOMEWORK.ID.eq(id.number)) + .toMono() + }.awaitSingleOrNull()?.toModel() } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/HomeworkMapping.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/HomeworkMapping.kt index e55510c..ce20825 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/HomeworkMapping.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/HomeworkMapping.kt @@ -11,4 +11,5 @@ fun HomeworkRecord.toModel(): Homework = maxScore = Homework.Score(this.maxScore), publicationMoment = this.publicationMoment, creationMoment = this.creationMoment!!, + deadlineMoment = this.deadlineMoment, ) diff --git a/botalka/src/main/resources/database/schema.sql b/botalka/src/main/resources/database/schema.sql index 52dbb54..4b73712 100644 --- a/botalka/src/main/resources/database/schema.sql +++ b/botalka/src/main/resources/database/schema.sql @@ -49,6 +49,7 @@ CREATE TABLE lms.homework ( description text NOT NULL, max_score lms.score NOT NULL, publication_moment timestamptz NOT NULL, + deadline_moment timestamptz NOT NULL, creation_moment timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/botalka/src/main/resources/static/openapi/api.yml b/botalka/src/main/resources/static/openapi/api.yml index dd8c61f..50f3cd8 100644 --- a/botalka/src/main/resources/static/openapi/api.yml +++ b/botalka/src/main/resources/static/openapi/api.yml @@ -111,7 +111,7 @@ paths: schema: $ref: '#/components/schemas/WorkspaceEvent' 400: - description: Event is invalid + description: Event is invalid or deadline has passed content: application/json: schema: @@ -296,6 +296,11 @@ components: format: date-time description: A moment when this homework must be published example: 2024-03-11T12:00:00Z + HomeworkDeadlineMoment: + type: string + format: date-time + description: A moment when is a deadline for this homework + example: 2024-03-11T12:00:00Z CreationMoment: type: string format: date-time @@ -312,11 +317,14 @@ components: $ref: '#/components/schemas/HomeworkScore' publication_moment: $ref: '#/components/schemas/HomeworkPublicationMoment' + deadline_moment: + $ref: '#/components/schemas/HomeworkDeadlineMoment' required: - title - description - max_score - publication_moment + - deadline_moment Homework: allOf: - $ref: '#/components/schemas/HomeworkDraft' diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/HomeworkApiTest.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/HomeworkApiTest.kt index 9e67211..8ef5e27 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/HomeworkApiTest.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/HomeworkApiTest.kt @@ -2,13 +2,16 @@ package ru.vityaman.lms.botalka.api.http.endpoint import io.kotest.matchers.collections.shouldBeUnique import io.kotest.matchers.date.shouldBeAfter +import io.kotest.matchers.date.shouldBeBefore import io.kotest.matchers.date.shouldHaveSameInstantAs import io.kotest.matchers.shouldBe import kotlinx.coroutines.async import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.reactive.function.client.WebClientResponseException import ru.vityaman.lms.botalka.BotalkaTestSuite import ru.vityaman.lms.botalka.api.http.client.HomeworkDraftMessage import ru.vityaman.lms.botalka.api.http.client.apis.HomeworkApi @@ -23,24 +26,28 @@ class HomeworkApiTest( description = "Test Homework 1 Description", maxScore = 100, publicationMoment = time("2024-04-01T10:15:30+03:00"), + deadlineMoment = time("2024-05-02T10:11:33+03:00"), ), HomeworkDraftMessage( title = "Test Homework 2", description = "Test Homework 2 Description", maxScore = 250, publicationMoment = time("2024-05-01T12:00:30+03:00"), + deadlineMoment = time("2024-07-01T12:11:30+03:00"), ), HomeworkDraftMessage( title = "Test Homework 3", description = "Test Homework 3 Description", maxScore = 100, publicationMoment = time("2024-05-02T12:00:30+03:00"), + deadlineMoment = time("2024-05-03T06:11:30+03:00"), ), HomeworkDraftMessage( title = "Test Homework 4", description = "Test Homework 4 Description", maxScore = 300, publicationMoment = time("2024-05-02T12:00:30+03:00"), + deadlineMoment = time("2024-05-05T06:10:30+03:00"), ), ) @@ -59,17 +66,74 @@ class HomeworkApiTest( l.description shouldBe r.description l.maxScore shouldBe r.maxScore l.publicationMoment shouldHaveSameInstantAs r.publicationMoment + l.deadlineMoment shouldHaveSameInstantAs r.deadlineMoment } + val endingPoint = OffsetDateTime.now() + val results = draftToResultList.map { it.second } results.forEach { it.creationMoment shouldBeAfter startingPoint + it.creationMoment shouldBeBefore endingPoint } val ids = results.map { it.id } ids.shouldBeUnique() } + @Test + fun homeworkInvariants(): Unit = runBlocking { + assertThrows { + verifyTime( + publicationMoment = time("2024-05-05T05:05:05+03:00"), + deadlineMoment = time("2024-05-04T05:05:05+03:00"), + ) + } + assertThrows { + verifyTime( + publicationMoment = time("2024-05-05T05:05:05+03:00"), + deadlineMoment = time("2024-05-05T05:05:45+03:00"), + ) + } + assertThrows { + verifyTime( + publicationMoment = time("2024-05-05T05:05:05+03:00"), + deadlineMoment = time("2024-05-05T05:06:05+03:00"), + ) + } + verifyTime( + publicationMoment = time("2024-05-05T05:05:05+03:00"), + deadlineMoment = time("2024-05-05T05:06:06+03:00"), + ) + } + + private suspend fun verifyTime( + publicationMoment: OffsetDateTime, + deadlineMoment: OffsetDateTime, + ) { + api.homeworkPost( + sampleHomework( + publicationMoment = publicationMoment, + deadlineMoment = deadlineMoment, + ), + ).awaitSingle() + } + private fun time(format: String): OffsetDateTime = OffsetDateTime.parse(format) + + @Suppress("LongParameterList") + private fun sampleHomework( + title: String = "Sample Test Homework", + description: String = "Sample Test Homework Description", + maxScore: Int = 100, + publicationMoment: OffsetDateTime = time("2024-05-02T12:00:30+03:00"), + deadlineMoment: OffsetDateTime = time("2024-05-03T06:11:30+03:00"), + ) = HomeworkDraftMessage( + title = title, + description = description, + maxScore = maxScore, + publicationMoment = publicationMoment, + deadlineMoment = deadlineMoment, + ) } diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/RatingApiTest.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/RatingApiTest.kt index 4161408..3c3f72c 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/RatingApiTest.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/RatingApiTest.kt @@ -35,6 +35,7 @@ class RatingApiTest( description = "Description Any", maxScore = 200, publicationMoment = OffsetDateTime.now(), + deadlineMoment = OffsetDateTime.now().plusDays(1), ) private val dummySubmit = diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/WorkspaceApiTest.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/WorkspaceApiTest.kt index a18da89..2c20625 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/WorkspaceApiTest.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/WorkspaceApiTest.kt @@ -8,7 +8,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.reactor.awaitSingle import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.reactive.function.client.WebClientResponseException import ru.vityaman.lms.botalka.BotalkaTestSuite import ru.vityaman.lms.botalka.api.http.client.HomeworkDraftMessage import ru.vityaman.lms.botalka.api.http.client.PromotionRequestDraftMessage @@ -22,12 +24,15 @@ import ru.vityaman.lms.botalka.api.http.client.WorkspaceFeedbackDraftMessage import ru.vityaman.lms.botalka.api.http.client.WorkspaceSubmissionDraftMessage import ru.vityaman.lms.botalka.api.http.client.apis.HomeworkApi import ru.vityaman.lms.botalka.api.http.client.apis.UserApi +import ru.vityaman.lms.botalka.env.FakeClock +import java.time.Instant import java.time.OffsetDateTime import kotlin.random.Random class WorkspaceApiTest( @Autowired private val homeworks: HomeworkApi, @Autowired private val users: UserApi, + @Autowired private val clock: FakeClock, ) : BotalkaTestSuite() { private var student: Int = 0 private var teacher: Int = 0 @@ -47,6 +52,7 @@ class WorkspaceApiTest( description = "Description Any", maxScore = 200, publicationMoment = OffsetDateTime.now(), + deadlineMoment = OffsetDateTime.now().plusDays(1), ), ).awaitSingle().id } @@ -84,6 +90,26 @@ class WorkspaceApiTest( .all { (a, b) -> a <= b } } + @Test + @Suppress("FunctionMaxLength") + fun cantSubmitSolutionAfterDeadline(): Unit = runBlocking { + homework = homeworks.homeworkPost( + HomeworkDraftMessage( + title = "Expired Homework", + description = "Description", + maxScore = 200, + publicationMoment = OffsetDateTime.now(), + deadlineMoment = OffsetDateTime.now().plusMinutes(1), + ), + ).awaitSingle().id + + clock.withFixedInstant(Instant.now().plusSeconds(60)) { + assertThrows { + submit("Oh, sorry, I am late...") + } + } + } + private suspend fun postRandomEvents(count: Int): Unit = coroutineScope { val random = Random(1_231_313) repeat(count) { diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/env/FakeClock.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/env/FakeClock.kt new file mode 100644 index 0000000..98d8466 --- /dev/null +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/env/FakeClock.kt @@ -0,0 +1,27 @@ +package ru.vityaman.lms.botalka.env + +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.Instant +import java.time.ZoneId + +@Primary +@Component +class FakeClock : Clock() { + private val origin = Clock.systemUTC() + private var instant: Instant? = null + + override fun instant(): Instant = + instant ?: origin.instant() + + override fun withZone(p0: ZoneId?): Clock = origin.withZone(p0) + + override fun getZone(): ZoneId = origin.zone + + suspend fun withFixedInstant(instant: Instant, action: suspend () -> Unit) { + this.instant = instant + action() + this.instant = null + } +}