diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 4af7ec1..aff0f9c 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -21,4 +21,6 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Build Gateway - run: (./gradlew :botalka:build) + run: |- + ./gradlew :botalka:jooqCodegen + ./gradlew :botalka:build diff --git a/botalka/build.gradle.kts b/botalka/build.gradle.kts index c26b56f..a5d6980 100644 --- a/botalka/build.gradle.kts +++ b/botalka/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("lms.conventions.jooq") id("lms.conventions.spring") id("lms.conventions.spring-oapi") + id("lms.conventions.kotlin-oapi") } val basePackage = "$group.lms.botalka" @@ -25,11 +26,17 @@ jooq { } } -val oapiPackageName = "$basePackage.api.http" +val oapiServerPackageName = "$basePackage.api.http.server" +val oapiClientPackageName = "$basePackage.api.http.client" -tasks.withType { - packageName = oapiPackageName - modelPackage = oapiPackageName +tasks.named("generateServer", OpenAPIGenerateTask::class) { + packageName = oapiServerPackageName + modelPackage = oapiServerPackageName +} + +tasks.named("generateClient", OpenAPIGenerateTask::class) { + packageName = oapiClientPackageName + modelPackage = oapiClientPackageName } koverReport { @@ -37,7 +44,8 @@ koverReport { excludes { classes( "$basePackage.BotalkaApplicationKt", - "$oapiPackageName.apis.**", + "$oapiServerPackageName.**", + "$oapiClientPackageName.**", "$jooqPackageName.**", ) } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/HomeworkHttpApi.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/HomeworkHttpApi.kt index 33ad203..8bb7b04 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/HomeworkHttpApi.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/HomeworkHttpApi.kt @@ -1,24 +1,59 @@ package ru.vityaman.lms.botalka.api.http.endpoint +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RestController -import ru.vityaman.lms.botalka.api.http.HomeworkDraftMessage -import ru.vityaman.lms.botalka.api.http.HomeworkMessage -import ru.vityaman.lms.botalka.api.http.apis.HomeworkApi import ru.vityaman.lms.botalka.api.http.message.toMessage import ru.vityaman.lms.botalka.api.http.message.toModel +import ru.vityaman.lms.botalka.api.http.server.HomeworkDraftMessage +import ru.vityaman.lms.botalka.api.http.server.HomeworkMessage +import ru.vityaman.lms.botalka.api.http.server.WorkspaceEventDraftMessage +import ru.vityaman.lms.botalka.api.http.server.WorkspaceEventMessage +import ru.vityaman.lms.botalka.api.http.server.apis.HomeworkApi +import ru.vityaman.lms.botalka.domain.model.Homework +import ru.vityaman.lms.botalka.domain.model.Student +import ru.vityaman.lms.botalka.domain.model.User +import ru.vityaman.lms.botalka.domain.model.Workspace import ru.vityaman.lms.botalka.logic.HomeworkService +import ru.vityaman.lms.botalka.logic.WorkspaceService @RestController class HomeworkHttpApi( - @Autowired private val service: HomeworkService, + @Autowired private val homework: HomeworkService, + @Autowired private val workspace: WorkspaceService, ) : HomeworkApi { override suspend fun homeworkPost( homeworkDraftMessage: HomeworkDraftMessage, ): ResponseEntity { val draft = homeworkDraftMessage.toModel() - val homework = service.create(draft) + val homework = homework.create(draft) return ResponseEntity.ok(homework.toMessage()) } + + override suspend fun postEvent( + homeworkId: Int, + studentId: Int, + producerId: Int, + workspaceEventDraftMessage: WorkspaceEventDraftMessage, + ): ResponseEntity { + val homework = Homework.Id(homeworkId) + val student = Student(User.Id(studentId)) + val producer = User.Id(producerId) + val draft = workspaceEventDraftMessage.toModel(producer) + val event = workspace.produce(Workspace.Id(homework, student), draft) + return ResponseEntity.ok(event.toMessage()) + } + + override fun getEvent( + homeworkId: Int, + studentId: Int, + ): ResponseEntity> { + val homework = Homework.Id(homeworkId) + val student = Student(User.Id(studentId)) + return workspace.events(Workspace.Id(homework, student)) + .map { it.toMessage() } + .let { ResponseEntity.ok(it) } + } } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/MonitoringHttpApi.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/MonitoringHttpApi.kt index 3045cae..86de03f 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/MonitoringHttpApi.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/MonitoringHttpApi.kt @@ -2,7 +2,7 @@ package ru.vityaman.lms.botalka.api.http.endpoint import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RestController -import ru.vityaman.lms.botalka.api.http.apis.MonitoringApi +import ru.vityaman.lms.botalka.api.http.server.apis.MonitoringApi @RestController class MonitoringHttpApi : MonitoringApi { diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/PromotionHttpApi.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/PromotionHttpApi.kt index c6ee1b8..1c2ee36 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/PromotionHttpApi.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/PromotionHttpApi.kt @@ -4,13 +4,13 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RestController -import ru.vityaman.lms.botalka.api.http.PromotionRequestDraftMessage -import ru.vityaman.lms.botalka.api.http.PromotionRequestMessage -import ru.vityaman.lms.botalka.api.http.PromotionRequestPatchMessage -import ru.vityaman.lms.botalka.api.http.apis.PromotionApi import ru.vityaman.lms.botalka.api.http.error.InvalidPromotionRequestStatus import ru.vityaman.lms.botalka.api.http.message.toMessage import ru.vityaman.lms.botalka.api.http.message.toModel +import ru.vityaman.lms.botalka.api.http.server.PromotionRequestDraftMessage +import ru.vityaman.lms.botalka.api.http.server.PromotionRequestMessage +import ru.vityaman.lms.botalka.api.http.server.PromotionRequestPatchMessage +import ru.vityaman.lms.botalka.api.http.server.apis.PromotionApi import ru.vityaman.lms.botalka.domain.model.PromotionRequest import ru.vityaman.lms.botalka.domain.model.User import ru.vityaman.lms.botalka.logic.PromotionService diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/UserHttpApi.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/UserHttpApi.kt index 7d3cf61..3a822c3 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/UserHttpApi.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/UserHttpApi.kt @@ -3,11 +3,11 @@ package ru.vityaman.lms.botalka.api.http.endpoint import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RestController -import ru.vityaman.lms.botalka.api.http.UserDraftMessage -import ru.vityaman.lms.botalka.api.http.UserMessage -import ru.vityaman.lms.botalka.api.http.apis.UserApi import ru.vityaman.lms.botalka.api.http.message.toMessage import ru.vityaman.lms.botalka.api.http.message.toModel +import ru.vityaman.lms.botalka.api.http.server.UserDraftMessage +import ru.vityaman.lms.botalka.api.http.server.UserMessage +import ru.vityaman.lms.botalka.api.http.server.apis.UserApi import ru.vityaman.lms.botalka.domain.model.User import ru.vityaman.lms.botalka.logic.UserService 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 54b77e0..c45d2e3 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 @@ -1,7 +1,7 @@ package ru.vityaman.lms.botalka.api.http.message -import ru.vityaman.lms.botalka.api.http.HomeworkDraftMessage -import ru.vityaman.lms.botalka.api.http.HomeworkMessage +import ru.vityaman.lms.botalka.api.http.server.HomeworkDraftMessage +import ru.vityaman.lms.botalka.api.http.server.HomeworkMessage import ru.vityaman.lms.botalka.domain.model.Homework fun Homework.toMessage(): HomeworkMessage = diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/PromotionMapping.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/PromotionMapping.kt index dbc8f91..4fa6d30 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/PromotionMapping.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/PromotionMapping.kt @@ -1,7 +1,7 @@ package ru.vityaman.lms.botalka.api.http.message -import ru.vityaman.lms.botalka.api.http.PromotionRequestMessage -import ru.vityaman.lms.botalka.api.http.PromotionRequestStatusMessage +import ru.vityaman.lms.botalka.api.http.server.PromotionRequestMessage +import ru.vityaman.lms.botalka.api.http.server.PromotionRequestStatusMessage import ru.vityaman.lms.botalka.commons.BiMap import ru.vityaman.lms.botalka.commons.BiMap.Companion.invoke import ru.vityaman.lms.botalka.domain.model.PromotionRequest diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/UserMapping.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/UserMapping.kt index c2a16cf..82caaac 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/UserMapping.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/UserMapping.kt @@ -1,8 +1,8 @@ package ru.vityaman.lms.botalka.api.http.message -import ru.vityaman.lms.botalka.api.http.UserDraftMessage -import ru.vityaman.lms.botalka.api.http.UserMessage -import ru.vityaman.lms.botalka.api.http.UserRoleMessage +import ru.vityaman.lms.botalka.api.http.server.UserDraftMessage +import ru.vityaman.lms.botalka.api.http.server.UserMessage +import ru.vityaman.lms.botalka.api.http.server.UserRoleMessage import ru.vityaman.lms.botalka.commons.BiMap import ru.vityaman.lms.botalka.commons.BiMap.Companion.invoke import ru.vityaman.lms.botalka.domain.model.User diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/WorkspaceMapping.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/WorkspaceMapping.kt new file mode 100644 index 0000000..faac678 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/api/http/message/WorkspaceMapping.kt @@ -0,0 +1,99 @@ +package ru.vityaman.lms.botalka.api.http.message + +import ru.vityaman.lms.botalka.api.http.server.WorkspaceCommentDraftMessage +import ru.vityaman.lms.botalka.api.http.server.WorkspaceCommentMessage +import ru.vityaman.lms.botalka.api.http.server.WorkspaceEventDraftMessage +import ru.vityaman.lms.botalka.api.http.server.WorkspaceEventKindMessage +import ru.vityaman.lms.botalka.api.http.server.WorkspaceEventMessage +import ru.vityaman.lms.botalka.api.http.server.WorkspaceFeedbackDraftMessage +import ru.vityaman.lms.botalka.api.http.server.WorkspaceFeedbackMessage +import ru.vityaman.lms.botalka.api.http.server.WorkspaceSubmissionDraftMessage +import ru.vityaman.lms.botalka.api.http.server.WorkspaceSubmissionMessage +import ru.vityaman.lms.botalka.domain.model.Homework +import ru.vityaman.lms.botalka.domain.model.Teacher +import ru.vityaman.lms.botalka.domain.model.User +import ru.vityaman.lms.botalka.domain.model.Workspace + +fun WorkspaceEventDraftMessage.toModel(producer: User.Id) = + when (this) { + is WorkspaceCommentDraftMessage -> { + this.toModel(producer) + } + + is WorkspaceSubmissionDraftMessage -> { + this.toModel(producer) + } + + is WorkspaceFeedbackDraftMessage -> { + this.toModel(producer) + } + + else -> { + throw NotImplementedError( + buildString { + append("WorkspaceEvent type '${this@toModel.kind}' ") + append("is not yet supported") + }, + ) + } + } + +fun WorkspaceCommentDraftMessage.toModel(producer: User.Id) = + Workspace.Comment.Draft( + producer = producer, + text = this.text, + ) + +fun WorkspaceSubmissionDraftMessage.toModel(producer: User.Id) = + Workspace.Submission.Draft( + producer = producer, + note = this.note, + ) + +fun WorkspaceFeedbackDraftMessage.toModel(producer: User.Id) = + Workspace.Feedback.Draft( + teacher = Teacher(producer), + comment = this.comment, + score = this.score?.let { Homework.Score(it.toShort()) }, + ) + +private val Workspace.Event.kind + get() = + when (this) { + is Workspace.Comment -> WorkspaceEventKindMessage.COMMENT + is Workspace.Feedback -> WorkspaceEventKindMessage.FEEDBACK + is Workspace.Submission -> WorkspaceEventKindMessage.SUBMISSION + } + +fun Workspace.Event.toMessage(): WorkspaceEventMessage = + when (this) { + is Workspace.Comment -> { + WorkspaceCommentMessage( + kind = this.kind, + id = this.id.number, + producerId = this.producer.number, + text = this.text, + creationMoment = this.creationMoment, + ) + } + + is Workspace.Feedback -> { + WorkspaceFeedbackMessage( + kind = this.kind, + id = this.id.number, + producerId = this.producer.number, + comment = this.comment, + creationMoment = this.creationMoment, + ) + } + + is Workspace.Submission -> { + WorkspaceSubmissionMessage( + kind = this.kind, + id = this.id.number, + producerId = this.producer.number, + note = this.note, + creationMoment = this.creationMoment, + ) + } + } diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/commons/Merge.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/commons/Merge.kt new file mode 100644 index 0000000..ac38951 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/commons/Merge.kt @@ -0,0 +1,37 @@ +package ru.vityaman.lms.botalka.commons + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.produceIn + +fun > mergeOrdered( + lhs: Flow, + rhs: Flow, + key: (T) -> U, +): Flow = channelFlow { + val lhsChan = lhs.produceIn(this) + val rhsChan = rhs.produceIn(this) + + var left = lhsChan.receiveCatching().getOrNull() + var right = rhsChan.receiveCatching().getOrNull() + + while (left != null && right != null) { + if (key(left) < key(right)) { + send(left) + left = lhsChan.receiveCatching().getOrNull() + } else { + send(right) + right = rhsChan.receiveCatching().getOrNull() + } + } + + while (left != null) { + send(left) + left = lhsChan.receiveCatching().getOrNull() + } + + while (right != null) { + send(right) + right = rhsChan.receiveCatching().getOrNull() + } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/domain/model/Workspace.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/domain/model/Workspace.kt new file mode 100644 index 0000000..ca9fdc4 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/domain/model/Workspace.kt @@ -0,0 +1,67 @@ +package ru.vityaman.lms.botalka.domain.model + +import ru.vityaman.lms.botalka.commons.expectId +import java.time.OffsetDateTime + +data class Workspace( + val id: Id, + val events: List, +) { + data class Id(val homework: Homework.Id, val student: Student) + + sealed class Event { + abstract val id: Id + abstract val producer: User.Id + abstract val creationMoment: OffsetDateTime + + @JvmInline + value class Id(val number: Int) { + init { + expectId(number) + } + } + + sealed class Draft + } + + data class Comment( + override val id: Id, + override val producer: User.Id, + val text: String, + override val creationMoment: OffsetDateTime, + ) : Event() { + data class Draft( + val producer: User.Id, + val text: String, + ) : Event.Draft() + } + + data class Submission( + override val id: Id, + override val producer: User.Id, + val note: String, + override val creationMoment: OffsetDateTime, + ) : Event() { + data class Draft( + val producer: User.Id, + val note: String, + ) : Event.Draft() + } + + data class Feedback( + override val id: Id, + val teacher: Teacher, + val comment: String, + val score: Homework.Score?, + override val creationMoment: OffsetDateTime, + ) : Event() { + data class Draft( + val teacher: Teacher, + val comment: String, + val score: Homework.Score?, + ) : Event.Draft() + + override val producer: User.Id + get() = teacher.id + } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/WorkspaceService.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/WorkspaceService.kt new file mode 100644 index 0000000..69cb582 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/WorkspaceService.kt @@ -0,0 +1,12 @@ +package ru.vityaman.lms.botalka.logic + +import kotlinx.coroutines.flow.Flow +import ru.vityaman.lms.botalka.domain.model.Workspace + +interface WorkspaceService { + fun events(id: Workspace.Id): Flow + suspend fun produce( + id: Workspace.Id, + event: Workspace.Event.Draft, + ): Workspace.Event +} 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 new file mode 100644 index 0000000..50ea24a --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/basic/BasicWorkspaceService.kt @@ -0,0 +1,26 @@ +package ru.vityaman.lms.botalka.logic.basic + +import kotlinx.coroutines.flow.Flow +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import ru.vityaman.lms.botalka.domain.model.Workspace +import ru.vityaman.lms.botalka.logic.WorkspaceService +import ru.vityaman.lms.botalka.storage.WorkspaceStorage + +@Service +class BasicWorkspaceService( + @Autowired private val storage: WorkspaceStorage, +) : WorkspaceService { + override fun events(id: Workspace.Id): Flow = + storage.events(id) + + override suspend fun produce( + id: Workspace.Id, + event: Workspace.Event.Draft, + ): Workspace.Event = + when (event) { + is Workspace.Comment.Draft -> storage.save(id, event) + is Workspace.Submission.Draft -> storage.save(id, event) + is Workspace.Feedback.Draft -> storage.save(id, event) + } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/facade/WorkspaceChat.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/facade/WorkspaceChat.kt new file mode 100644 index 0000000..328e806 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/logic/facade/WorkspaceChat.kt @@ -0,0 +1,28 @@ +package ru.vityaman.lms.botalka.logic.facade + +import kotlinx.coroutines.flow.Flow +import ru.vityaman.lms.botalka.domain.model.Workspace +import ru.vityaman.lms.botalka.logic.WorkspaceService + +class WorkspaceChat( + private val id: Workspace.Id, + private val service: WorkspaceService, +) { + fun history(): Flow = + service.events(id) + + suspend fun comment( + comment: Workspace.Comment.Draft, + ): Workspace.Comment = + service.produce(id, comment) as Workspace.Comment + + suspend fun submit( + submission: Workspace.Submission.Draft, + ): Workspace.Submission = + service.produce(id, submission) as Workspace.Submission + + suspend fun feedback( + feedback: Workspace.Feedback.Draft, + ): Workspace.Feedback = + service.produce(id, feedback) as Workspace.Feedback +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/WorkspaceStorage.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/WorkspaceStorage.kt new file mode 100644 index 0000000..05dcb25 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/WorkspaceStorage.kt @@ -0,0 +1,23 @@ +package ru.vityaman.lms.botalka.storage + +import kotlinx.coroutines.flow.Flow +import ru.vityaman.lms.botalka.domain.model.Workspace + +interface WorkspaceStorage { + fun events(id: Workspace.Id): Flow + + suspend fun save( + id: Workspace.Id, + event: Workspace.Comment.Draft, + ): Workspace.Comment + + suspend fun save( + id: Workspace.Id, + event: Workspace.Submission.Draft, + ): Workspace.Submission + + suspend fun save( + id: Workspace.Id, + event: Workspace.Feedback.Draft, + ): Workspace.Feedback +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqWorkspaceStorage.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqWorkspaceStorage.kt new file mode 100644 index 0000000..3f5561c --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/JooqWorkspaceStorage.kt @@ -0,0 +1,98 @@ +package ru.vityaman.lms.botalka.storage.jooq + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactor.awaitSingle +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Repository +import reactor.kotlin.core.publisher.toMono +import ru.vityaman.lms.botalka.commons.mergeOrdered +import ru.vityaman.lms.botalka.domain.model.Workspace +import ru.vityaman.lms.botalka.storage.WorkspaceStorage +import ru.vityaman.lms.botalka.storage.jooq.entity.toModel +import ru.vityaman.lms.botalka.storage.jooq.tables.references.HOMEWORK_FEEDBACK +import ru.vityaman.lms.botalka.storage.jooq.tables.references.HOMEWORK_SUBMISSION + +@Repository +class JooqWorkspaceStorage( + @Autowired private val database: JooqDatabase, +) : WorkspaceStorage { + override fun events(id: Workspace.Id): Flow { + val submissionWorkspaceEq = + HOMEWORK_SUBMISSION.HOMEWORK_ID.eq(id.homework.number) + .and(HOMEWORK_SUBMISSION.STUDENT_ID.eq(id.student.id.number)) + + val feedbackWorkspaceEq = + HOMEWORK_FEEDBACK.HOMEWORK_ID.eq(id.homework.number) + .and(HOMEWORK_FEEDBACK.STUDENT_ID.eq(id.student.id.number)) + + val submissions: Flow = database.execute + .selectFrom(HOMEWORK_SUBMISSION) + .where(submissionWorkspaceEq) + .orderBy(HOMEWORK_SUBMISSION.CREATION_MOMENT) + .coerce(HOMEWORK_SUBMISSION) + .toFlux() + .asFlow() + .map { it.toModel() } + + val feedbacks: Flow = database.execute + .selectFrom(HOMEWORK_FEEDBACK) + .where(feedbackWorkspaceEq) + .orderBy(HOMEWORK_FEEDBACK.CREATION_MOMENT) + .coerce(HOMEWORK_FEEDBACK) + .toFlux() + .asFlow() + .map { it.toModel() } + + return mergeOrdered(submissions, feedbacks) { it.creationMoment } + } + + override suspend fun save( + id: Workspace.Id, + event: Workspace.Comment.Draft, + ): Workspace.Comment = + TODO("Postponed") + + override suspend fun save( + id: Workspace.Id, + event: Workspace.Submission.Draft, + ): Workspace.Submission = + database.execute + .insertInto( + HOMEWORK_SUBMISSION, + HOMEWORK_SUBMISSION.HOMEWORK_ID, + HOMEWORK_SUBMISSION.STUDENT_ID, + HOMEWORK_SUBMISSION.COMMENT, + ) + .values(id.homework.number, id.student.id.number, event.note) + .returning() + .coerce(HOMEWORK_SUBMISSION) + .toMono() + .map { it.toModel() } + .awaitSingle() + + override suspend fun save( + id: Workspace.Id, + event: Workspace.Feedback.Draft, + ): Workspace.Feedback = + database.execute + .insertInto( + HOMEWORK_FEEDBACK, + HOMEWORK_FEEDBACK.HOMEWORK_ID, + HOMEWORK_FEEDBACK.STUDENT_ID, + HOMEWORK_FEEDBACK.TEACHER_ID, + HOMEWORK_FEEDBACK.COMMENT, + ) + .values( + id.homework.number, + id.student.id.number, + event.teacher.id.number, + event.comment, + ) + .returning() + .coerce(HOMEWORK_FEEDBACK) + .toMono() + .map { it.toModel() } + .awaitSingle() +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/WorkspaceMapping.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/WorkspaceMapping.kt new file mode 100644 index 0000000..a729587 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/WorkspaceMapping.kt @@ -0,0 +1,25 @@ +package ru.vityaman.lms.botalka.storage.jooq.entity + +import ru.vityaman.lms.botalka.domain.model.Homework +import ru.vityaman.lms.botalka.domain.model.Teacher +import ru.vityaman.lms.botalka.domain.model.User +import ru.vityaman.lms.botalka.domain.model.Workspace +import ru.vityaman.lms.botalka.storage.jooq.tables.records.HomeworkFeedbackRecord +import ru.vityaman.lms.botalka.storage.jooq.tables.records.HomeworkSubmissionRecord + +fun HomeworkSubmissionRecord.toModel() = + Workspace.Submission( + id = Workspace.Event.Id(this.id!!), + producer = User.Id(this.studentId), + note = this.comment, + creationMoment = this.creationMoment!!, + ) + +fun HomeworkFeedbackRecord.toModel() = + Workspace.Feedback( + id = Workspace.Event.Id(this.id!!), + teacher = Teacher(User.Id(this.teacherId)), + comment = this.comment, + score = this.score?.let { Homework.Score(it) }, + creationMoment = this.creationMoment!!, + ) diff --git a/botalka/src/main/resources/database/schema.sql b/botalka/src/main/resources/database/schema.sql index 1bdd406..deccf9f 100644 --- a/botalka/src/main/resources/database/schema.sql +++ b/botalka/src/main/resources/database/schema.sql @@ -61,7 +61,7 @@ CREATE TABLE lms.homework_submission ( ); CREATE TABLE lms.homework_feedback ( - id integer PRIMARY KEY, + id serial PRIMARY KEY, homework_id integer NOT NULL REFERENCES lms.homework(id), student_id integer NOT NULL REFERENCES lms.student(user_id), teacher_id integer NOT NULL REFERENCES lms.teacher(user_id), diff --git a/botalka/src/main/resources/static/openapi/api.yml b/botalka/src/main/resources/static/openapi/api.yml index ca33769..db2605b 100644 --- a/botalka/src/main/resources/static/openapi/api.yml +++ b/botalka/src/main/resources/static/openapi/api.yml @@ -43,11 +43,72 @@ paths: application/json: schema: $ref: '#/components/schemas/GeneralError' + /homework/{homework_id}/workspace/{student_id}/event: + get: + tags: [ Homework ] + operationId: getEvent + summary: Get all events at the given workspace + description: Events are sorted by creation moment + parameters: + - name: homework_id + in: path + required: true + schema: + $ref: '#/components/schemas/HomeworkId' + - name: student_id + in: path + required: true + schema: + $ref: '#/components/schemas/UserId' + responses: + 200: + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WorkspaceEvent' + post: + tags: [ Homework ] + operationId: postEvent + summary: Produce a workspace event + description: Send a comment, submission or feedback + parameters: + - name: homework_id + in: path + required: true + schema: + $ref: '#/components/schemas/HomeworkId' + - name: student_id + in: path + required: true + schema: + $ref: '#/components/schemas/UserId' + - name: producer_id + in: header + required: true + schema: + $ref: '#/components/schemas/UserId' + requestBody: + description: A draft of event to produce + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WorkspaceEventDraft' + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/WorkspaceEvent' /user/{id}: get: tags: [ User ] summary: Find a user by its id - description: + description: Find a user by its id parameters: - name: id in: path @@ -71,7 +132,7 @@ paths: post: tags: [ User ] summary: Creates a new user - description: + description: Creates a new user requestBody: description: A draft of user to create required: true @@ -224,6 +285,141 @@ components: required: - id - creation_moment + WorkspaceCommentDraft: + allOf: + - $ref: '#/components/schemas/WorkspaceEventDraft' + - type: object + properties: + text: + type: string + description: A comment text + example: Sorry, I have a question about task statement + required: + - text + WorkspaceComment: + allOf: + - $ref: '#/components/schemas/WorkspaceEvent' + - type: object + properties: + text: + type: string + description: A comment text + example: Sorry, I have a question about task statement + required: + - text + WorkspaceSubmissionDraft: + allOf: + - $ref: '#/components/schemas/WorkspaceEventDraft' + - type: object + properties: + note: + type: string + description: A submission note or description + example: Here is a solved problems, but I'm not sure about last one + required: + - note + WorkspaceSubmission: + allOf: + - $ref: '#/components/schemas/WorkspaceEvent' + - type: object + properties: + note: + type: string + description: A submission note or description + example: Here is a solved problems, but I'm not sure about last one + required: + - note + WorkspaceFeedbackDraft: + allOf: + - $ref: '#/components/schemas/WorkspaceEventDraft' + - type: object + properties: + comment: + type: string + description: A feedback comment + example: LGTM, but there was some problem with threads + score: + $ref: '#/components/schemas/HomeworkScore' + required: + - comment + WorkspaceFeedback: + allOf: + - $ref: '#/components/schemas/WorkspaceEvent' + - type: object + properties: + comment: + type: string + description: A feedback comment + example: LGTM, but there was some problem with threads + score: + $ref: '#/components/schemas/HomeworkScore' + required: + - comment + WorkspaceEventId: + type: integer + description: A unique key for of a workspace event + example: 321 + WorkspaceEventKind: + type: string + description: A kind to distinguish events + enum: + - comment + - submission + - feedback + example: comment + WorkspaceEventDraft: + type: object + discriminator: + propertyName: kind + mapping: + comment: '#/components/schemas/WorkspaceCommentDraft' + submission: '#/components/schemas/WorkspaceSubmissionDraft' + feedback: '#/components/schemas/WorkspaceFeedbackDraft' + properties: + kind: + $ref: '#/components/schemas/WorkspaceEventKind' + required: + - kind + WorkspaceEvent: + type: object + discriminator: + propertyName: kind + mapping: + comment: '#/components/schemas/WorkspaceComment' + submission: '#/components/schemas/WorkspaceSubmission' + feedback: '#/components/schemas/WorkspaceFeedback' + properties: + id: + $ref: '#/components/schemas/WorkspaceEventId' + kind: + $ref: '#/components/schemas/WorkspaceEventKind' + producer_id: + $ref: '#/components/schemas/UserId' + creation_moment: + $ref: '#/components/schemas/CreationMoment' + required: + - id + - kind + - producer_id + - creation_moment + Workspace: + type: object + properties: + homework_id: + $ref: '#/components/schemas/HomeworkId' + student_id: + $ref: '#/components/schemas/UserId' + events: + type: array + description: Messages sorted by creation moment + items: + $ref: '#/components/schemas/WorkspaceEvent' + score: + $ref: '#/components/schemas/HomeworkScore' + required: + - homework_id + - student_id + - events UserId: type: integer description: A unique key for a user at the entire system diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/BotalkaTestSuite.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/BotalkaTestSuite.kt index a490377..6a12a54 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/BotalkaTestSuite.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/BotalkaTestSuite.kt @@ -1,34 +1,36 @@ package ru.vityaman.lms.botalka -import io.kotest.common.runBlocking -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.runBlocking import org.jooq.DSLContext import org.junit.jupiter.api.AfterEach import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ContextConfiguration import ru.vityaman.lms.botalka.storage.DatabaseContainerInitializer import ru.vityaman.lms.botalka.storage.jooq.Lms.Companion.LMS import ru.vityaman.lms.botalka.storage.jooq.toFlux +@SpringBootTest( + classes = [BotalkaApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, + useMainMethod = SpringBootTest.UseMainMethod.ALWAYS, +) @ContextConfiguration(initializers = [DatabaseContainerInitializer::class]) -open class BotalkaTestSuite { +abstract class BotalkaTestSuite { @Autowired private lateinit var database: DSLContext @AfterEach - fun afterEach() { - runBlocking { - coroutineScope { - for (table in LMS.tables) { - launch { - database.deleteFrom(table) - .toFlux() - .asFlow() - .collect {} - } - } + fun afterEach(): Unit = runBlocking { + for (table in LMS.tables) { + launch { + database + .query("TRUNCATE ${LMS.name}.${table.name} CASCADE") + .toFlux() + .asFlow() + .collect {} } } } diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/ApiClientSet.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/ApiClientSet.kt new file mode 100644 index 0000000..42724e9 --- /dev/null +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/ApiClientSet.kt @@ -0,0 +1,21 @@ +package ru.vityaman.lms.botalka.api.http.client + +import org.springframework.context.annotation.Bean +import org.springframework.stereotype.Component +import ru.vityaman.lms.botalka.api.http.client.apis.HomeworkApi +import ru.vityaman.lms.botalka.api.http.client.apis.MonitoringApi +import ru.vityaman.lms.botalka.api.http.client.apis.UserApi + +@Component +class ApiClientSet { + private val url = "http://localhost:8080/api/v1" + + @Bean + fun monitoringApi() = MonitoringApi(url) + + @Bean + fun homeworkApi() = HomeworkApi(url) + + @Bean + fun userApi() = UserApi(url) +} diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/HomeworkApiClient.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/HomeworkApiClient.kt new file mode 100644 index 0000000..5c15209 --- /dev/null +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/HomeworkApiClient.kt @@ -0,0 +1,53 @@ +package ru.vityaman.lms.botalka.api.http.client + +import kotlinx.coroutines.reactor.awaitSingle +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import ru.vityaman.lms.botalka.api.http.client.apis.HomeworkApi +import ru.vityaman.lms.botalka.domain.model.Homework +import ru.vityaman.lms.botalka.domain.model.Workspace + +@Service +class HomeworkApiClient( + @Autowired private val api: HomeworkApi, +) { + suspend fun create(draft: Homework.Draft): Homework.Id = + api.homeworkPost( + HomeworkDraftMessage( + title = draft.title.text, + description = draft.title.text, + maxScore = draft.maxScore.value.toInt(), + publicationMoment = draft.publicationMoment, + ), + ).map { Homework.Id(it.id) }.awaitSingle() + + suspend fun submit(id: Workspace.Id, draft: Workspace.Submission.Draft) { + api.postEvent( + homeworkId = id.homework.number, + studentId = id.student.id.number, + producerId = draft.producer.number, + WorkspaceSubmissionDraftMessage( + kind = WorkspaceEventKindMessage.submission, + note = draft.note, + ), + ).awaitSingle() + } + + suspend fun feedback(id: Workspace.Id, draft: Workspace.Feedback.Draft) { + api.postEvent( + homeworkId = id.homework.number, + studentId = id.student.id.number, + producerId = draft.teacher.id.number, + WorkspaceFeedbackDraftMessage( + kind = WorkspaceEventKindMessage.feedback, + comment = draft.comment, + ), + ).awaitSingle() + } + + suspend fun history(id: Workspace.Id): List = + api.getEvent( + homeworkId = id.homework.number, + studentId = id.student.id.number, + ).awaitSingle() +} diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/UserApiClient.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/UserApiClient.kt new file mode 100644 index 0000000..5e08525 --- /dev/null +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/client/UserApiClient.kt @@ -0,0 +1,41 @@ +package ru.vityaman.lms.botalka.api.http.client + +import kotlinx.coroutines.reactor.awaitSingle +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import ru.vityaman.lms.botalka.api.http.client.apis.UserApi +import ru.vityaman.lms.botalka.domain.model.PromotionRequest +import ru.vityaman.lms.botalka.domain.model.User +import ru.vityaman.lms.botalka.domain.model.User.Role.STUDENT +import ru.vityaman.lms.botalka.domain.model.User.Role.TEACHER + +@Service +class UserApiClient(@Autowired private val api: UserApi) { + suspend fun register(alias: User.Alias): User.Id = + User.Draft(alias) + .let { api.userPost(UserDraftMessage(it.alias.text)) } + .map { User.Id(it.id) } + .awaitSingle() + + suspend fun get(id: User.Id): User = + api.userIdGet(id.number) + .map { User(User.Id(it.id), User.Alias(it.alias), emptySet()) } + .awaitSingle() + + suspend fun promote(user: User.Id, role: User.Role): PromotionRequest.Id { + val request = PromotionRequestDraftMessage( + when (role) { + TEACHER -> UserRoleMessage.teacher + STUDENT -> UserRoleMessage.student + }, + ) + val id = api.promotionRequestPost(user.number, request).awaitSingle().id + return PromotionRequest.Id(id) + } + + suspend fun approve(id: PromotionRequest.Id) { + val status = PromotionRequestStatusMessage.approved + val patch = PromotionRequestPatchMessage(status) + api.promotionRequestIdPatch(id.number, patch).awaitSingle() + } +} 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 6baa335..dcba7a1 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 @@ -10,8 +10,8 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import ru.vityaman.lms.botalka.BotalkaTestSuite -import ru.vityaman.lms.botalka.api.http.apis.HomeworkApi import ru.vityaman.lms.botalka.api.http.message.toMessage +import ru.vityaman.lms.botalka.api.http.server.apis.HomeworkApi import ru.vityaman.lms.botalka.domain.model.Homework import java.time.OffsetDateTime diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/MonitoringApiTest.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/MonitoringApiTest.kt index 0150b65..101bae3 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/MonitoringApiTest.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/MonitoringApiTest.kt @@ -1,16 +1,19 @@ package ru.vityaman.lms.botalka.api.http.endpoint +import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest +import ru.vityaman.lms.botalka.BotalkaTestSuite +import ru.vityaman.lms.botalka.api.http.client.apis.MonitoringApi import kotlin.test.assertEquals -@SpringBootTest -class MonitoringApiTest(@Autowired val api: MonitoringHttpApi) { +class MonitoringApiTest( + @Autowired private val monitoring: MonitoringApi, +) : BotalkaTestSuite() { @Test - fun pingSucceeds() { - val body = runBlocking { api.monitoringPingGet().body } + fun pingSucceeds(): Unit = runBlocking { + val body = monitoring.monitoringPingGet().awaitSingle() assertEquals(body, "pong") } } diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/PromoteApiTest.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/PromoteApiTest.kt index 1fd4663..f48c874 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/PromoteApiTest.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/PromoteApiTest.kt @@ -7,13 +7,13 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import ru.vityaman.lms.botalka.BotalkaTestSuite -import ru.vityaman.lms.botalka.api.http.PromotionRequestDraftMessage -import ru.vityaman.lms.botalka.api.http.PromotionRequestPatchMessage -import ru.vityaman.lms.botalka.api.http.PromotionRequestStatusMessage -import ru.vityaman.lms.botalka.api.http.apis.PromotionApi -import ru.vityaman.lms.botalka.api.http.apis.UserApi import ru.vityaman.lms.botalka.api.http.message.toMessage import ru.vityaman.lms.botalka.api.http.message.toModel +import ru.vityaman.lms.botalka.api.http.server.PromotionRequestDraftMessage +import ru.vityaman.lms.botalka.api.http.server.PromotionRequestPatchMessage +import ru.vityaman.lms.botalka.api.http.server.PromotionRequestStatusMessage +import ru.vityaman.lms.botalka.api.http.server.apis.PromotionApi +import ru.vityaman.lms.botalka.api.http.server.apis.UserApi import ru.vityaman.lms.botalka.domain.exception.PromotionRequestResolvedException import ru.vityaman.lms.botalka.domain.model.PromotionRequest import ru.vityaman.lms.botalka.domain.model.User diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/UserApiTest.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/UserApiTest.kt index b595b3f..f4f8750 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/UserApiTest.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/UserApiTest.kt @@ -11,8 +11,8 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import ru.vityaman.lms.botalka.BotalkaTestSuite -import ru.vityaman.lms.botalka.api.http.apis.UserApi import ru.vityaman.lms.botalka.api.http.message.toMessage +import ru.vityaman.lms.botalka.api.http.server.apis.UserApi import ru.vityaman.lms.botalka.domain.model.User @SpringBootTest 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 new file mode 100644 index 0000000..9114f7f --- /dev/null +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/api/http/endpoint/WorkspaceApiTest.kt @@ -0,0 +1,76 @@ +package ru.vityaman.lms.botalka.api.http.endpoint + +import io.kotest.common.runBlocking +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import ru.vityaman.lms.botalka.BotalkaTestSuite +import ru.vityaman.lms.botalka.api.http.client.HomeworkApiClient +import ru.vityaman.lms.botalka.api.http.client.UserApiClient +import ru.vityaman.lms.botalka.api.http.client.WorkspaceEventKindMessage +import ru.vityaman.lms.botalka.domain.model.Homework +import ru.vityaman.lms.botalka.domain.model.Student +import ru.vityaman.lms.botalka.domain.model.Teacher +import ru.vityaman.lms.botalka.domain.model.User +import ru.vityaman.lms.botalka.domain.model.Workspace +import java.time.OffsetDateTime + +class WorkspaceApiTest( + @Autowired private val homeworks: HomeworkApiClient, + @Autowired private val users: UserApiClient, +) : BotalkaTestSuite() { + private lateinit var student: Student + private lateinit var teacher: Teacher + private lateinit var workspace: Workspace.Id + + @BeforeEach + fun establishPreconditions(): Unit = runBlocking { + student = Student(users.register(User.Alias("student"))) + teacher = Teacher(users.register(User.Alias("teacher"))) + + users.approve(users.promote(student.id, User.Role.STUDENT)) + users.approve(users.promote(teacher.id, User.Role.TEACHER)) + + val homework = homeworks.create( + Homework.Draft( + Homework.Title("Title Any"), + Homework.Description("Description Any"), + Homework.Score(200), + OffsetDateTime.now(), + ), + ) + + workspace = Workspace.Id(homework, student) + } + + @Test + fun smoking(): Unit = runBlocking { + homeworks.history(workspace) shouldHaveSize 0 + + homeworks.submit( + workspace, + Workspace.Submission.Draft( + producer = student.id, + note = "Note", + ), + ) + + homeworks.history(workspace) shouldHaveSize 1 + + homeworks.feedback( + workspace, + Workspace.Feedback.Draft( + teacher = teacher, + comment = "Comment", + score = null, + ), + ) + + val history = homeworks.history(workspace) + history shouldHaveSize 2 + history[0].kind shouldBe WorkspaceEventKindMessage.submission + history[1].kind shouldBe WorkspaceEventKindMessage.feedback + } +} diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/commons/MergeTest.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/commons/MergeTest.kt new file mode 100644 index 0000000..d58eb3d --- /dev/null +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/commons/MergeTest.kt @@ -0,0 +1,43 @@ +package ru.vityaman.lms.botalka.commons + +import io.kotest.common.ExperimentalKotest +import io.kotest.common.runBlocking +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.intArray +import io.kotest.property.arbitrary.map +import io.kotest.property.checkAll +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.toList + +fun arbSortedList() = + Arb.intArray( + length = Arb.int(0..50), + content = Arb.int(-20..20), + ).map { it.asList().sorted() } + +fun silly(lhs: List, rhs: List) = + (lhs + rhs).sorted() + +fun smart(lhs: List, rhs: List) = + runBlocking { + mergeOrdered(lhs.asFlow(), rhs.asFlow()) { it }.toList() + } + +@OptIn(ExperimentalKotest::class) +class MergeTest : StringSpec({ + "Produces sorted sequence" { + checkAll( + PropTestConfig(iterations = 50), + Arb.bind(arbSortedList(), arbSortedList()) { lhs, rhs -> + Pair(lhs, rhs) + }, + ) { (lhs, rhs) -> + smart(lhs, rhs).shouldContainExactly(silly(lhs, rhs)) + } + } +}) diff --git a/buildSrc/src/main/kotlin/lms.conventions.jooq.gradle.kts b/buildSrc/src/main/kotlin/lms.conventions.jooq.gradle.kts index 9ab832a..9ac64c4 100644 --- a/buildSrc/src/main/kotlin/lms.conventions.jooq.gradle.kts +++ b/buildSrc/src/main/kotlin/lms.conventions.jooq.gradle.kts @@ -73,6 +73,7 @@ jooq { } } -tasks.compileKotlin.configure { - dependsOn(tasks.jooqCodegen) -} +// Commented to save compilation time +// tasks.compileKotlin.configure { +// dependsOn(tasks.jooqCodegen) +// } diff --git a/buildSrc/src/main/kotlin/lms.conventions.kotlin-oapi.gradle.kts b/buildSrc/src/main/kotlin/lms.conventions.kotlin-oapi.gradle.kts new file mode 100644 index 0000000..3b198fc --- /dev/null +++ b/buildSrc/src/main/kotlin/lms.conventions.kotlin-oapi.gradle.kts @@ -0,0 +1,44 @@ +import org.openapitools.generator.gradle.plugin.tasks.GenerateTask as OpenAPIGenerateTask + +plugins { + id("lms.conventions.runtime") + id("org.openapi.generator") +} + +val generatedDir = "$projectDir/build/generated" + +val oapiTaskName = "generateClient" + +tasks.register(oapiTaskName) { + val spec = "$projectDir/src/main/resources/static/openapi/api.yml" + + group = "openapi" + description = "Generates code from an Open API specification" + verbose = false + generatorName = "kotlin" + inputSpec = spec + outputDir = "$generatedDir/openapi-client" + // packageName = ... + // modelPackage = ... + modelNameSuffix = "Message" + generateModelTests = true + generateApiTests = false + configOptions = + mapOf( + "library" to "jvm-spring-webclient", + "useSpringBoot3" to "true", + "serializationLibrary" to "jackson", + ) +} + +tasks.compileKotlin.configure { + dependsOn(tasks.getByName(oapiTaskName)) +} + +sourceSets { + main { + java { + srcDir("$generatedDir/openapi-client/src/main/kotlin") + } + } +} diff --git a/buildSrc/src/main/kotlin/lms.conventions.spring-oapi.gradle.kts b/buildSrc/src/main/kotlin/lms.conventions.spring-oapi.gradle.kts index 636b710..cf86dff 100644 --- a/buildSrc/src/main/kotlin/lms.conventions.spring-oapi.gradle.kts +++ b/buildSrc/src/main/kotlin/lms.conventions.spring-oapi.gradle.kts @@ -2,7 +2,6 @@ import org.openapitools.generator.gradle.plugin.tasks.GenerateTask as OpenAPIGen plugins { id("lms.conventions.runtime") - id("org.openapi.generator") } @@ -13,7 +12,7 @@ dependencies { } val generatedDir = "$projectDir/build/generated" -val oapiTaskName = "generateController" +val oapiTaskName = "generateServer" tasks.register(oapiTaskName) { val spec = "$projectDir/src/main/resources/static/openapi/api.yml" @@ -23,7 +22,7 @@ tasks.register(oapiTaskName) { verbose = false generatorName = "kotlin-spring" inputSpec = spec - outputDir = "$generatedDir/openapi" + outputDir = "$generatedDir/openapi-server" // packageName = ... // modelPackage = ... modelNameSuffix = "Message" @@ -37,6 +36,7 @@ tasks.register(oapiTaskName) { "openApiNullable" to "false", "reactive" to "true", "interfaceOnly" to "true", + "skipDefaultInterface" to "true", ) } @@ -47,7 +47,7 @@ tasks.compileKotlin.configure { sourceSets { main { java { - srcDir("$generatedDir/openapi/src/main/kotlin") + srcDir("$generatedDir/openapi-server/src/main/kotlin") } } } diff --git a/config/detekt.yml b/config/detekt.yml index d7b2188..cc7f1b4 100644 --- a/config/detekt.yml +++ b/config/detekt.yml @@ -36,8 +36,8 @@ complexity: threshold: 30 LongParameterList: active: true - functionThreshold: 3 - constructorThreshold: 4 + functionThreshold: 4 + constructorThreshold: 5 MethodOverloading: active: true NamedArguments: