From 731351c8350521fc3e430349d539585eb0da6f38 Mon Sep 17 00:00:00 2001 From: Victor Smirnov <53015676+vityaman@users.noreply.github.com> Date: Thu, 28 Mar 2024 22:09:02 +0300 Subject: [PATCH] #21 Added endpoint to POST /homework (#26) --- .editorconfig | 1 + README.md | 16 ++- botalka/build.gradle.kts | 30 +++-- .../api/http/endpoint/HomeworkHttpApi.kt | 24 ++++ .../api/http/message/HomeworkMapping.kt | 31 +++++ .../ru/itmo/lms/botalka/commons/Abbreviate.kt | 6 + .../itmo/lms/botalka/domain/model/Homework.kt | 78 ++++++++++++ .../itmo/lms/botalka/logic/HomeworkService.kt | 7 ++ .../logic/basic/BasicHomeworkService.kt | 15 +++ .../lms/botalka/storage/HomeworkStorage.kt | 7 ++ .../lms/botalka/storage/jooq/JooqDatabase.kt | 8 ++ .../storage/jooq/JooqHomeworkStorage.kt | 35 ++++++ .../botalka/storage/jooq/JooqNoteStorage.kt | 9 +- .../storage/jooq/entity/HomeworkMapping.kt | 14 +++ .../src/main/resources/database/schema.sql | 47 +++++++ .../src/main/resources/static/openapi/api.yml | 117 +++++++++++++++++- .../ru/itmo/lms/botalka/BaseTestSuite.kt | 48 ------- .../lms/botalka/TestContainerInitializer.kt | 38 ++++++ .../api/http/endpoint/HomeworkApiTest.kt | 80 ++++++++++++ .../botalka/api/http/endpoint/NotesApiTest.kt | 10 +- 20 files changed, 554 insertions(+), 67 deletions(-) create mode 100644 botalka/src/main/kotlin/ru/itmo/lms/botalka/api/http/endpoint/HomeworkHttpApi.kt create mode 100644 botalka/src/main/kotlin/ru/itmo/lms/botalka/api/http/message/HomeworkMapping.kt create mode 100644 botalka/src/main/kotlin/ru/itmo/lms/botalka/commons/Abbreviate.kt create mode 100644 botalka/src/main/kotlin/ru/itmo/lms/botalka/domain/model/Homework.kt create mode 100644 botalka/src/main/kotlin/ru/itmo/lms/botalka/logic/HomeworkService.kt create mode 100644 botalka/src/main/kotlin/ru/itmo/lms/botalka/logic/basic/BasicHomeworkService.kt create mode 100644 botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/HomeworkStorage.kt create mode 100644 botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqDatabase.kt create mode 100644 botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqHomeworkStorage.kt create mode 100644 botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/entity/HomeworkMapping.kt delete mode 100644 botalka/src/test/kotlin/ru/itmo/lms/botalka/BaseTestSuite.kt create mode 100644 botalka/src/test/kotlin/ru/itmo/lms/botalka/TestContainerInitializer.kt create mode 100644 botalka/src/test/kotlin/ru/itmo/lms/botalka/api/http/endpoint/HomeworkApiTest.kt diff --git a/.editorconfig b/.editorconfig index 20f9fae..e95784b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,4 @@ [*.{kt,kts}] ktlint_code_style = intellij_idea ktlint_standard_no-wildcard-imports = disabled +ij_continuation_indent_size = 4 diff --git a/README.md b/README.md index 57117f4..12478bb 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,22 @@ A simple learning management system. ## Build & Run +### Build the Botalka Service + +```bash +gradle :botalka:bootJar +``` + +### Start infrastructure + ```bash -gradle :botalka:build docker compose down docker compose up --build --force-recreate ``` + +### Connect to LMS Database + +```bash +docker exec -it lms-database bash +psql -h localhost -p 5432 -d $POSTGRES_DB -U $POSTGRES_USER +``` diff --git a/botalka/build.gradle.kts b/botalka/build.gradle.kts index 2cc49c0..1eb3e85 100644 --- a/botalka/build.gradle.kts +++ b/botalka/build.gradle.kts @@ -10,7 +10,7 @@ plugins { id("org.springframework.boot") version "3.2.3" id("io.spring.dependency-management") version "1.1.4" - id("org.openapi.generator") version "5.3.0" + id("org.openapi.generator") version "7.4.0" id("org.jlleitschuh.gradle.ktlint") version "12.1.0" id("io.gitlab.arturbosch.detekt") version "1.23.5" @@ -28,6 +28,7 @@ val basePackage = "$group.lms.botalka" val jooqVersion = "3.19.6" val testcontainersVersion = "1.19.7" +val kotestVersion = "5.8.1" java { sourceCompatibility = JavaVersion.VERSION_21 @@ -55,10 +56,13 @@ dependencies { implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:2.4.0") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("jakarta.validation:jakarta.validation-api:3.0.2") implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") runtimeOnly("org.postgresql:r2dbc-postgresql") + implementation("org.apache.commons:commons-lang3:3.14.0") + implementation("org.jooq:jooq:$jooqVersion") implementation("org.jooq:jooq-kotlin:$jooqVersion") jooqCodegen("jakarta.xml.bind:jakarta.xml.bind-api:4.0.2") @@ -69,8 +73,13 @@ dependencies { jooqCodegen("org.testcontainers:testcontainers:$testcontainersVersion") testImplementation("org.testcontainers:r2dbc:$testcontainersVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + testImplementation("io.kotest:kotest-property:$kotestVersion") + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:postgresql") testImplementation("io.projectreactor:reactor-test") @@ -106,17 +115,14 @@ tasks.register(generateControllers) { packageName = pkg modelPackage = pkg modelNameSuffix = "Message" - generateModelTests = false + generateModelTests = true generateApiTests = false configOptions = mapOf( + "useSpringBoot3" to "true", "serializationLibrary" to "jackson", + "dateLibrary" to "kotlinx-datetime", "enumPropertyNaming" to "UPPERCASE", - "dateLibrary" to "java8", - "bigDecimalAsString" to "true", - "hideGenerationTimestamp" to "true", - "useBeanValidation" to "false", - "performBeanValidation" to "false", "openApiNullable" to "false", "reactive" to "true", "interfaceOnly" to "true", @@ -194,7 +200,13 @@ koverReport { } jooq { - val schemaSql = "$projectDir/src/main/resources/database/schema.sql" + val jdbcUrl = { + val schemaSql = "$projectDir/src/main/resources/database/schema.sql" + val protocol = "jdbc:tc:postgresql:16" + val tmpfs = "TC_TMPFS=/testtmpfs:rw&" + val script = "TC_INITSCRIPT=file:$schemaSql" + "$protocol:///test?$tmpfs;$script" + } executions { create("main") { @@ -202,7 +214,7 @@ jooq { logging = org.jooq.meta.jaxb.Logging.DEBUG jdbc { driver = "org.testcontainers.jdbc.ContainerDatabaseDriver" - url = "jdbc:tc:postgresql:16:///test?TC_TMPFS=/testtmpfs:rw&TC_INITSCRIPT=file:$schemaSql" + url = jdbcUrl() username = "postgres" password = "postgres" } diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/api/http/endpoint/HomeworkHttpApi.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/api/http/endpoint/HomeworkHttpApi.kt new file mode 100644 index 0000000..ac4159c --- /dev/null +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/api/http/endpoint/HomeworkHttpApi.kt @@ -0,0 +1,24 @@ +package ru.itmo.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.itmo.lms.botalka.api.http.HomeworkDraftMessage +import ru.itmo.lms.botalka.api.http.HomeworkMessage +import ru.itmo.lms.botalka.api.http.apis.HomeworkApi +import ru.itmo.lms.botalka.api.http.message.toMessage +import ru.itmo.lms.botalka.api.http.message.toModel +import ru.itmo.lms.botalka.logic.HomeworkService + +@RestController +class HomeworkHttpApi( + @Autowired private val service: HomeworkService, +) : HomeworkApi { + override suspend fun homeworkPost( + homeworkDraftMessage: HomeworkDraftMessage, + ): ResponseEntity { + val draft = homeworkDraftMessage.toModel() + val homework = service.create(draft) + return ResponseEntity.ok(homework.toMessage()) + } +} diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/api/http/message/HomeworkMapping.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/api/http/message/HomeworkMapping.kt new file mode 100644 index 0000000..63d2c80 --- /dev/null +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/api/http/message/HomeworkMapping.kt @@ -0,0 +1,31 @@ +package ru.itmo.lms.botalka.api.http.message + +import ru.itmo.lms.botalka.api.http.HomeworkDraftMessage +import ru.itmo.lms.botalka.api.http.HomeworkMessage +import ru.itmo.lms.botalka.domain.model.Homework + +fun Homework.toMessage(): HomeworkMessage = + HomeworkMessage( + id = this.id.number, + title = this.title.text, + description = this.description.text, + maxScore = this.maxScore.value.toInt(), + publicationMoment = this.publicationMoment, + creationMoment = this.creationMoment, + ) + +fun Homework.Draft.toMessage(): HomeworkDraftMessage = + HomeworkDraftMessage( + title = this.title.text, + description = this.description.text, + maxScore = this.maxScore.value.toInt(), + publicationMoment = this.publicationMoment, + ) + +fun HomeworkDraftMessage.toModel(): Homework.Draft = + Homework.Draft( + title = Homework.Title(this.title), + description = Homework.Description(this.description), + maxScore = Homework.Score(this.maxScore.toShort()), + publicationMoment = this.publicationMoment, + ) diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/commons/Abbreviate.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/commons/Abbreviate.kt new file mode 100644 index 0000000..cff5cd3 --- /dev/null +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/commons/Abbreviate.kt @@ -0,0 +1,6 @@ +package ru.itmo.lms.botalka.commons + +import org.apache.commons.lang3.StringUtils + +fun String.abbreviated(maxLength: Int = 8): String = + StringUtils.abbreviate(this, maxLength) diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/domain/model/Homework.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/domain/model/Homework.kt new file mode 100644 index 0000000..12ef439 --- /dev/null +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/domain/model/Homework.kt @@ -0,0 +1,78 @@ +package ru.itmo.lms.botalka.domain.model + +import ru.itmo.lms.botalka.commons.abbreviated +import java.time.OffsetDateTime + +data class Homework( + val id: Id, + val title: Title, + val description: Description, + val maxScore: Score, + val publicationMoment: OffsetDateTime, + val creationMoment: OffsetDateTime, +) { + @JvmInline + value class Id(val number: Int) { + init { + require(0 < number) { + """ + Unique id must be a positive, got $number + """.trimIndent() + } + } + } + + @JvmInline + value class Title(val text: String) { + init { + require(text.length in lengthRange) { + """ + Title must be in range $lengthRange, + got ${text.abbreviated()} with length ${text.length} + """.trimIndent() + } + } + + companion object { + private val lengthRange = 8..64 + } + } + + @JvmInline + value class Description(val text: String) { + init { + require(text.length in lengthRange) { + """ + Description must be in range $lengthRange, " + got ${text.abbreviated()} with length ${text.length} + """.trimIndent() + } + } + + companion object { + private val lengthRange = 8..16_384 + } + } + + @JvmInline + value class Score(val value: Short) { + init { + require(value in range) { + """ + Score must be in range $range, got $value + """.trimIndent() + } + } + + companion object { + private val range = 0..2000 + } + } + + data class Draft( + val title: Title, + val description: Description, + val maxScore: Score, + val publicationMoment: OffsetDateTime, + ) +} diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/logic/HomeworkService.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/logic/HomeworkService.kt new file mode 100644 index 0000000..e4d11f1 --- /dev/null +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/logic/HomeworkService.kt @@ -0,0 +1,7 @@ +package ru.itmo.lms.botalka.logic + +import ru.itmo.lms.botalka.domain.model.Homework + +interface HomeworkService { + suspend fun create(homework: Homework.Draft): Homework +} diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/logic/basic/BasicHomeworkService.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/logic/basic/BasicHomeworkService.kt new file mode 100644 index 0000000..0e2537b --- /dev/null +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/logic/basic/BasicHomeworkService.kt @@ -0,0 +1,15 @@ +package ru.itmo.lms.botalka.logic.basic + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import ru.itmo.lms.botalka.domain.model.Homework +import ru.itmo.lms.botalka.logic.HomeworkService +import ru.itmo.lms.botalka.storage.HomeworkStorage + +@Service +class BasicHomeworkService( + @Autowired private val storage: HomeworkStorage, +) : HomeworkService { + override suspend fun create(homework: Homework.Draft): Homework = + storage.create(homework) +} diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/HomeworkStorage.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/HomeworkStorage.kt new file mode 100644 index 0000000..cf3945c --- /dev/null +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/HomeworkStorage.kt @@ -0,0 +1,7 @@ +package ru.itmo.lms.botalka.storage + +import ru.itmo.lms.botalka.domain.model.Homework + +interface HomeworkStorage { + suspend fun create(homework: Homework.Draft): Homework +} diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqDatabase.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqDatabase.kt new file mode 100644 index 0000000..bb8957a --- /dev/null +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqDatabase.kt @@ -0,0 +1,8 @@ +package ru.itmo.lms.botalka.storage.jooq + +import org.jooq.DSLContext +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +@Component +data class JooqDatabase(@Autowired val execute: DSLContext) diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqHomeworkStorage.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqHomeworkStorage.kt new file mode 100644 index 0000000..65c32cd --- /dev/null +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqHomeworkStorage.kt @@ -0,0 +1,35 @@ +package ru.itmo.lms.botalka.storage.jooq + +import kotlinx.coroutines.reactor.awaitSingle +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Repository +import ru.itmo.lms.botalka.domain.model.Homework +import ru.itmo.lms.botalka.storage.HomeworkStorage +import ru.itmo.lms.botalka.storage.jooq.entity.toModel +import ru.itmo.lms.botalka.storage.jooq.tables.references.HOMEWORK + +@Repository +class JooqHomeworkStorage( + @Autowired private val database: JooqDatabase, +) : HomeworkStorage { + override suspend fun create(homework: Homework.Draft): Homework = + database.execute + .insertInto( + HOMEWORK, + HOMEWORK.TITLE, + HOMEWORK.DESCRIPTION, + HOMEWORK.MAX_SCORE, + HOMEWORK.PUBLICATION_MOMENT, + ) + .values( + homework.title.text, + homework.description.text, + homework.maxScore.value, + homework.publicationMoment, + ) + .returningResult(HOMEWORK.fields().asList()) + .coerce(HOMEWORK) + .toMono() + .map { it.toModel() } + .awaitSingle() +} diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqNoteStorage.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqNoteStorage.kt index af714a2..ded89d4 100644 --- a/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqNoteStorage.kt +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/JooqNoteStorage.kt @@ -3,7 +3,6 @@ package ru.itmo.lms.botalka.storage.jooq import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.reactive.awaitSingle -import org.jooq.DSLContext import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository import ru.itmo.lms.botalka.domain.model.Note @@ -14,16 +13,18 @@ import ru.itmo.lms.botalka.storage.jooq.tables.references.NOTE @Repository class JooqNoteStorage( - @Autowired private val dsl: DSLContext, + @Autowired private val database: JooqDatabase, ) : NoteStorage { override fun getAll(): Flow = - dsl.selectFrom(NOTE) + database.execute + .selectFrom(NOTE) .toFlux() .map { it.toModel() } .asFlow() override suspend fun create(note: Note.Draft): Note = - dsl.insertInto(NOTE, NOTE.CONTENT) + database.execute + .insertInto(NOTE, NOTE.CONTENT) .values(note.content) .returningResult(NOTE.ID, NOTE.CONTENT) .toMono() diff --git a/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/entity/HomeworkMapping.kt b/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/entity/HomeworkMapping.kt new file mode 100644 index 0000000..1dc8ba6 --- /dev/null +++ b/botalka/src/main/kotlin/ru/itmo/lms/botalka/storage/jooq/entity/HomeworkMapping.kt @@ -0,0 +1,14 @@ +package ru.itmo.lms.botalka.storage.jooq.entity + +import ru.itmo.lms.botalka.domain.model.Homework +import ru.itmo.lms.botalka.storage.jooq.tables.records.HomeworkRecord + +fun HomeworkRecord.toModel(): Homework = + Homework( + id = Homework.Id(this.id!!), + title = Homework.Title(this.title), + description = Homework.Description(this.description), + maxScore = Homework.Score(this.maxScore), + publicationMoment = this.publicationMoment, + creationMoment = this.creationMoment!!, + ) diff --git a/botalka/src/main/resources/database/schema.sql b/botalka/src/main/resources/database/schema.sql index 985e3d4..732523a 100644 --- a/botalka/src/main/resources/database/schema.sql +++ b/botalka/src/main/resources/database/schema.sql @@ -4,3 +4,50 @@ CREATE TABLE lms.note ( id bigserial PRIMARY KEY, content text NOT NULL ); + +CREATE DOMAIN lms.alias AS VARCHAR(32) +CHECK (VALUE ~ '[a-zA-Z''-]{3,31}'); + +CREATE TABLE lms.user ( + id serial PRIMARY KEY, + alias lms.alias UNIQUE NOT NULL, + creation_moment timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE lms.teacher ( + user_id integer PRIMARY KEY REFERENCES lms.user(id) +); + +CREATE TABLE lms.student ( + user_id integer PRIMARY KEY REFERENCES lms.user(id) +); + +CREATE DOMAIN lms.score AS smallint +CHECK (0 < VALUE AND VALUE <= 2000); + +CREATE TABLE lms.homework ( + id serial PRIMARY KEY, + title varchar(64) NOT NULL, + description text NOT NULL, + max_score lms.score NOT NULL, + publication_moment timestamptz NOT NULL, + creation_moment timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE lms.homework_submission ( + id serial PRIMARY KEY, + homework_id integer NOT NULL REFERENCES lms.homework(id), + student_id integer NOT NULL REFERENCES lms.student(user_id), + comment text NOT NULL, + creation_moment timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE lms.homework_feedback ( + id integer 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), + comment text NOT NULL, + score lms.score, -- NULLABLE + 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 7e2b999..3b27a58 100644 --- a/botalka/src/main/resources/static/openapi/api.yml +++ b/botalka/src/main/resources/static/openapi/api.yml @@ -8,8 +8,8 @@ paths: /monitoring/ping: get: tags: [ Monitoring ] - summary: Проверяет, жив ли сервис - description: Вернет pong, если сервис жив, иначе будем плакать. + summary: Checks if service is alive + description: Returns 'pong', if service is alive, else we will cry responses: 200: description: OK @@ -18,6 +18,31 @@ paths: schema: type: string example: pong + /homework: + post: + tags: [ Homework ] + summary: Creates a homework + description: Creates a homework to be published at specified time + requestBody: + description: A draft of homework to post + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/HomeworkDraft' + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Homework' + 400: + description: Homework is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' /notes: get: tags: [ Notes ] @@ -52,6 +77,78 @@ paths: $ref: '#/components/schemas/Note' components: schemas: + HomeworkId: + type: integer + description: A unique key for of a homework + example: 143 + HomeworkTitle: + type: string + description: Short Description of the Task + example: Serial lock-free Mutex with symmetrical control transmission + HomeworkDescription: + type: string + description: Long Description of the Task + example: | + 1. Implement synchronization primitives: + 1.1 Event + 1.2 Mutex + 1.3 Wait Group + 2. Optimize dynamic allocations + 3. Write a Strand from the fiber/strand task + 4. Write a lock-free implementation of synchronization primitives + 5. Write a serial lock-free Mutex with symmetrical control transmission + HomeworkScore: + type: integer + description: A grade for completion of task + example: 500 + HomeworkPublicationMoment: + type: string + format: date-time + description: A moment when this homework must be published + example: 2024-03-11T12:00:00Z + CreationMoment: + type: string + format: date-time + description: A moment when this object was created + example: 2024-04-14T13:32:42Z + HomeworkDraft: + type: object + required: + - title + - description + - max_score + - publication_moment + properties: + title: + $ref: '#/components/schemas/HomeworkTitle' + description: + $ref: '#/components/schemas/HomeworkDescription' + max_score: + $ref: '#/components/schemas/HomeworkScore' + publication_moment: + $ref: '#/components/schemas/HomeworkPublicationMoment' + Homework: + type: object + required: + - id + - title + - description + - max_score + - publication_moment + - creation_moment + properties: + id: + $ref: '#/components/schemas/HomeworkId' + title: + $ref: '#/components/schemas/HomeworkTitle' + description: + $ref: '#/components/schemas/HomeworkDescription' + max_score: + $ref: '#/components/schemas/HomeworkScore' + publication_moment: + $ref: '#/components/schemas/HomeworkPublicationMoment' + creation_moment: + $ref: '#/components/schemas/CreationMoment' NoteDraft: required: - content @@ -70,3 +167,19 @@ components: format: int64 content: type: string + GeneralError: + type: object + properties: + code: + type: integer + format: int32 + description: HTTP Status Code + example: 400 + status: + type: string + description: HTTP Status Description + example: Bad Request + message: + type: string + description: Detailed Message + example: Username must contain only latin letter diff --git a/botalka/src/test/kotlin/ru/itmo/lms/botalka/BaseTestSuite.kt b/botalka/src/test/kotlin/ru/itmo/lms/botalka/BaseTestSuite.kt deleted file mode 100644 index 132a65d..0000000 --- a/botalka/src/test/kotlin/ru/itmo/lms/botalka/BaseTestSuite.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ru.itmo.lms.botalka - -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.BeforeAll -import org.springframework.test.context.DynamicPropertyRegistry -import org.springframework.test.context.DynamicPropertySource -import org.testcontainers.containers.PostgreSQLContainer -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.utility.MountableFile - -@Suppress("UtilityClassWithPublicConstructor") -abstract class BaseTestSuite { - companion object { - @Container - private val postgres = PostgreSQLContainer("postgres") - .withCopyToContainer( - MountableFile.forClasspathResource("database/schema.sql"), - "/docker-entrypoint-initdb.d/init.sql", - ) - - @JvmStatic - @BeforeAll - fun beforeAll() { - postgres.start() - } - - @JvmStatic - @AfterAll - fun afterAll() { - postgres.stop() - } - - @JvmStatic - @DynamicPropertySource - fun configureProperties(registry: DynamicPropertyRegistry) { - registry.add("spring.r2dbc.url") { r2dbcUrl() } - registry.add("spring.r2dbc.username") { postgres.username } - registry.add("spring.r2dbc.password") { postgres.password } - } - - private fun r2dbcUrl(): String { - val host = postgres.host - val port = postgres.firstMappedPort - val name = postgres.databaseName - return "r2dbc:postgresql://$host:$port/$name" - } - } -} diff --git a/botalka/src/test/kotlin/ru/itmo/lms/botalka/TestContainerInitializer.kt b/botalka/src/test/kotlin/ru/itmo/lms/botalka/TestContainerInitializer.kt new file mode 100644 index 0000000..2b80a71 --- /dev/null +++ b/botalka/src/test/kotlin/ru/itmo/lms/botalka/TestContainerInitializer.kt @@ -0,0 +1,38 @@ +package ru.itmo.lms.botalka + +import org.springframework.boot.test.util.TestPropertyValues +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.ConfigurableApplicationContext +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.utility.MountableFile + +class TestContainerInitializer : + ApplicationContextInitializer { + + @Container + private val postgres = PostgreSQLContainer("postgres") + .withCopyToContainer( + MountableFile.forClasspathResource("database/schema.sql"), + "/docker-entrypoint-initdb.d/init.sql", + ) + + init { + postgres.start() + } + + override fun initialize(ctx: ConfigurableApplicationContext) { + TestPropertyValues.of( + "spring.r2dbc.url=${postgres.r2dbcUrl()}", + "spring.r2dbc.username=${postgres.username}", + "spring.r2dbc.password=${postgres.password}", + ).applyTo(ctx.environment) + } + + private fun PostgreSQLContainer<*>.r2dbcUrl(): String { + val host = this.host + val port = this.firstMappedPort + val name = this.databaseName + return "r2dbc:postgresql://$host:$port/$name" + } +} diff --git a/botalka/src/test/kotlin/ru/itmo/lms/botalka/api/http/endpoint/HomeworkApiTest.kt b/botalka/src/test/kotlin/ru/itmo/lms/botalka/api/http/endpoint/HomeworkApiTest.kt new file mode 100644 index 0000000..98ee985 --- /dev/null +++ b/botalka/src/test/kotlin/ru/itmo/lms/botalka/api/http/endpoint/HomeworkApiTest.kt @@ -0,0 +1,80 @@ +package ru.itmo.lms.botalka.api.http.endpoint + +import io.kotest.matchers.collections.shouldBeUnique +import io.kotest.matchers.date.shouldBeAfter +import io.kotest.matchers.date.shouldHaveSameInstantAs +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.async +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 org.springframework.test.context.ContextConfiguration +import ru.itmo.lms.botalka.TestContainerInitializer +import ru.itmo.lms.botalka.api.http.apis.HomeworkApi +import ru.itmo.lms.botalka.api.http.message.toMessage +import ru.itmo.lms.botalka.domain.model.Homework +import java.time.OffsetDateTime + +@SpringBootTest +@ContextConfiguration(initializers = [TestContainerInitializer::class]) +class HomeworkApiTest( + @Autowired val api: HomeworkApi, +) { + private val drafts = listOf( + Homework.Draft( + Homework.Title("Test Homework 1"), + Homework.Description("Test Homework 1 Description"), + Homework.Score(100), + OffsetDateTime.parse("2024-04-01T10:15:30+03:00"), + ), + Homework.Draft( + Homework.Title("Test Homework 2"), + Homework.Description("Test Homework 2 Description"), + Homework.Score(250), + OffsetDateTime.parse("2024-05-01T12:00:30+03:00"), + ), + Homework.Draft( + Homework.Title("Test Homework 3"), + Homework.Description("Test Homework 3 Description"), + Homework.Score(100), + OffsetDateTime.parse("2024-05-02T12:00:30+03:00"), + ), + Homework.Draft( + Homework.Title("Test Homework 4"), + Homework.Description("Test Homework 4 Description"), + Homework.Score(300), + OffsetDateTime.parse("2024-05-02T12:00:30+03:00"), + ), + ) + + @Test + fun createHomework() { + val startingPoint = OffsetDateTime.now() + + val draftToResultList = runBlocking { + drafts.map { draft -> + val message = draft.toMessage() + val result = async { api.homeworkPost(message).body } + Pair(draft, result) + }.map { (draft, result) -> + Pair(draft, result.await() ?: throw NullPointerException()) + } + } + + draftToResultList.forEach { (l, r) -> + l.title.text shouldBe r.title + l.description.text shouldBe r.description + l.maxScore.value shouldBe r.maxScore + l.publicationMoment shouldHaveSameInstantAs r.publicationMoment + } + + val results = draftToResultList.map { it.second } + results.forEach { + it.creationMoment shouldBeAfter startingPoint + } + + val ids = results.map { it.id } + ids.shouldBeUnique() + } +} diff --git a/botalka/src/test/kotlin/ru/itmo/lms/botalka/api/http/endpoint/NotesApiTest.kt b/botalka/src/test/kotlin/ru/itmo/lms/botalka/api/http/endpoint/NotesApiTest.kt index 197d9ae..b50592c 100644 --- a/botalka/src/test/kotlin/ru/itmo/lms/botalka/api/http/endpoint/NotesApiTest.kt +++ b/botalka/src/test/kotlin/ru/itmo/lms/botalka/api/http/endpoint/NotesApiTest.kt @@ -2,19 +2,23 @@ package ru.itmo.lms.botalka.api.http.endpoint import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.* import org.junit.jupiter.api.MethodOrderer.OrderAnnotation +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest -import ru.itmo.lms.botalka.BaseTestSuite +import org.springframework.test.context.ContextConfiguration +import ru.itmo.lms.botalka.TestContainerInitializer import ru.itmo.lms.botalka.api.http.NoteDraftMessage import ru.itmo.lms.botalka.api.http.NoteMessage import kotlin.test.assertContentEquals import kotlin.test.assertEquals @SpringBootTest +@ContextConfiguration(initializers = [TestContainerInitializer::class]) @TestMethodOrder(OrderAnnotation::class) -class NotesApiTest(@Autowired private val api: NotesHttpApi) : BaseTestSuite() { +class NotesApiTest(@Autowired private val api: NotesHttpApi) { private val contents = listOf( "Example note content", "It is just a text, nothing more",