From 2e99fd926c147c9564be2970a410e05b35f95ffd Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Sun, 27 Sep 2026 22:17:59 +0300 Subject: [PATCH] Add ContentImporter tests; drop redundant elvis; document lint/test commands --- README.md | 14 +++ .../academic/content/ContentImporterTest.kt | 96 +++++++++++++++++++ .../kotlin/ru/ysu/lms/web/api/ApiClient.kt | 36 +++---- 3 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt diff --git a/README.md b/README.md index 9d6239f..80ab141 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,20 @@ UML- и архитектурные диаграммы (Mermaid) — в ката Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`, progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. +## Качество кода и тесты + +```bash +./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты +./gradlew ktlintFormat # автоформат Kotlin +``` + +- `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки). + Оба подключены во всех Kotlin-модулях конвенцией `lms.lint` и **ломают сборку** при замечаниях. +- Конфигурация: `.editorconfig`, `config/detekt/detekt.yml`. +- Gradle daemon фиксируется на JDK 21 (`gradle/gradle-daemon-jvm.properties`): detekt 1.23 + не работает на JDK 25. +- Тестовая инфраструктура — `libs:test-support` (JUnit 5, MockK, Testcontainers `postgres:18`). + ## Запуск в Docker (для сервера) На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt new file mode 100644 index 0000000..96dd7fd --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt @@ -0,0 +1,96 @@ +package ru.ysu.lms.academic.content + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.ysu.lms.academic.domain.ContentArticle +import ru.ysu.lms.academic.domain.ContentChapter +import ru.ysu.lms.academic.domain.ContentSection +import ru.ysu.lms.academic.repo.ContentArticleRepository +import ru.ysu.lms.academic.repo.ContentChapterRepository +import ru.ysu.lms.academic.repo.ContentSectionRepository +import java.io.ByteArrayOutputStream +import java.nio.file.Path +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class ContentImporterTest { + @TempDir + lateinit var tempDir: Path + + private val chapters = mockk() + private val sections = mockk() + private val articles = mockk() + + private fun importer(): ContentImporter { + every { chapters.findByTitleIgnoreCase(any()) } returns null + every { chapters.findBySlug(any()) } returns null + every { chapters.findAllByOrderByPositionAscIdAsc() } returns emptyList() + every { chapters.save(any()) } answers { firstArg() } + + every { sections.findByChapterIdAndTitleIgnoreCase(any(), any()) } returns null + every { sections.findByChapterIdAndSlug(any(), any()) } returns null + every { sections.findByChapterIdOrderByPositionAscIdAsc(any()) } returns emptyList() + every { sections.save(any()) } answers { firstArg() } + + every { articles.findBySectionIdAndTitleIgnoreCase(any(), any()) } returns null + every { articles.findBySectionIdAndSlug(any(), any()) } returns null + every { articles.save(any()) } answers { firstArg() } + + return ContentImporter(chapters, sections, articles, AssetStorage(tempDir.toString())) + } + + private fun articleZip( + title: String, + slug: String, + ): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zos -> + zos.putNextEntry(ZipEntry("chapters/01-Глава/01-Раздел/01-Статья.md")) + zos.write("---\ntitle: \"$title\"\nslug: \"$slug\"\n---\nТекст статьи".toByteArray()) + zos.closeEntry() + } + return out.toByteArray() + } + + @Test + fun `creates chapter, section and article with title-based slug`() { + val importer = importer() + val savedChapter = slot() + val savedArticle = slot() + every { chapters.save(capture(savedChapter)) } answers { firstArg() } + every { articles.save(capture(savedArticle)) } answers { firstArg() } + + val result = importer.importZip("content.zip", articleZip("Ввод-вывод", "c-input"), sync = false) + + assertThat(result.chapters).isEqualTo(1) + assertThat(result.sections).isEqualTo(1) + assertThat(result.articles).isEqualTo(1) + assertThat(savedChapter.captured.title).isEqualTo("Глава") + assertThat(savedChapter.captured.slug).isEqualTo("glava") + assertThat(savedArticle.captured.title).isEqualTo("Ввод-вывод") + assertThat(savedArticle.captured.slug).isEqualTo("c-input") + } + + @Test + fun `reuses existing chapter and section by title instead of duplicating`() { + val importer = importer() + val chapter = ContentChapter(title = "Глава", slug = "glava", position = 1) + val section = ContentSection(chapterId = chapter.id, title = "Раздел", slug = "razdel", position = 1) + every { chapters.findByTitleIgnoreCase("Глава") } returns chapter + every { sections.findByChapterIdAndTitleIgnoreCase(chapter.id, "Раздел") } returns section + val chapterSaves = mutableListOf() + every { chapters.save(any()) } answers { + firstArg().also { chapterSaves.add(it.id) } + } + + val result = importer.importZip("content.zip", articleZip("Тема", "c-topic"), sync = false) + + assertThat(result.chapters).isEqualTo(1) + assertThat(chapterSaves).containsExactly(chapter.id) + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index a7ce4a2..99b55c8 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -84,7 +84,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun createGroup( @@ -155,7 +155,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun enrollStudent( @@ -264,7 +264,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun labDetail( @@ -344,7 +344,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun setLevel( @@ -404,7 +404,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun meAssignments(token: String): List { @@ -416,7 +416,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun updateLab( @@ -495,7 +495,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun setGrade( @@ -525,7 +525,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun myGrades(token: String): List = myGrades(token, null) @@ -547,7 +547,7 @@ class ApiClient( .get() .uri(if (groupId.isNullOrBlank()) uri else uri, *if (groupId.isNullOrBlank()) emptyArray() else arrayOf(groupId)) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)).retrieve().body(type) - } ?: emptyList() + } } fun groupPublications( @@ -562,7 +562,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun publishGrades( @@ -590,7 +590,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun submitLab( @@ -617,7 +617,7 @@ class ApiClient( .body(parts) .retrieve() .body(type) - } ?: emptyList() + } } fun mySubmissions( @@ -632,7 +632,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun labSubmissions( @@ -647,7 +647,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun downloadMySubmission( @@ -752,7 +752,7 @@ class ApiClient( (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) .retrieve() .body(type) - } ?: emptyList() + } } /** Сохраняет новое расположение глав, разделов и статей. */ @@ -1135,7 +1135,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun assignDefense( @@ -1166,7 +1166,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(Map::class.java) - }?.get("code").toString() + }.get("code").toString() fun resetDefense( token: String, @@ -1191,7 +1191,7 @@ class ApiClient( .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) - } ?: emptyList() + } } fun myDefense(