Add ContentImporter tests; drop redundant elvis; document lint/test commands
This commit is contained in:
14
README.md
14
README.md
@@ -48,6 +48,20 @@ UML- и архитектурные диаграммы (Mermaid) — в ката
|
|||||||
Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`,
|
Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`,
|
||||||
progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
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 (для сервера)
|
## Запуск в Docker (для сервера)
|
||||||
|
|
||||||
На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage
|
На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage
|
||||||
|
|||||||
@@ -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<ContentChapterRepository>()
|
||||||
|
private val sections = mockk<ContentSectionRepository>()
|
||||||
|
private val articles = mockk<ContentArticleRepository>()
|
||||||
|
|
||||||
|
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<ContentChapter>()
|
||||||
|
val savedArticle = slot<ContentArticle>()
|
||||||
|
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<UUID>()
|
||||||
|
every { chapters.save(any()) } answers {
|
||||||
|
firstArg<ContentChapter>().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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createGroup(
|
fun createGroup(
|
||||||
@@ -155,7 +155,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enrollStudent(
|
fun enrollStudent(
|
||||||
@@ -264,7 +264,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun labDetail(
|
fun labDetail(
|
||||||
@@ -344,7 +344,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLevel(
|
fun setLevel(
|
||||||
@@ -404,7 +404,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun meAssignments(token: String): List<MeAssignmentDto> {
|
fun meAssignments(token: String): List<MeAssignmentDto> {
|
||||||
@@ -416,7 +416,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLab(
|
fun updateLab(
|
||||||
@@ -495,7 +495,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setGrade(
|
fun setGrade(
|
||||||
@@ -525,7 +525,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun myGrades(token: String): List<GradeDto> = myGrades(token, null)
|
fun myGrades(token: String): List<GradeDto> = myGrades(token, null)
|
||||||
@@ -547,7 +547,7 @@ class ApiClient(
|
|||||||
.get()
|
.get()
|
||||||
.uri(if (groupId.isNullOrBlank()) uri else uri, *if (groupId.isNullOrBlank()) emptyArray() else arrayOf(groupId))
|
.uri(if (groupId.isNullOrBlank()) uri else uri, *if (groupId.isNullOrBlank()) emptyArray() else arrayOf(groupId))
|
||||||
spec.header(HttpHeaders.AUTHORIZATION, bearer(token)).retrieve().body(type)
|
spec.header(HttpHeaders.AUTHORIZATION, bearer(token)).retrieve().body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun groupPublications(
|
fun groupPublications(
|
||||||
@@ -562,7 +562,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun publishGrades(
|
fun publishGrades(
|
||||||
@@ -590,7 +590,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun submitLab(
|
fun submitLab(
|
||||||
@@ -617,7 +617,7 @@ class ApiClient(
|
|||||||
.body(parts)
|
.body(parts)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mySubmissions(
|
fun mySubmissions(
|
||||||
@@ -632,7 +632,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun labSubmissions(
|
fun labSubmissions(
|
||||||
@@ -647,7 +647,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadMySubmission(
|
fun downloadMySubmission(
|
||||||
@@ -752,7 +752,7 @@ class ApiClient(
|
|||||||
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
|
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сохраняет новое расположение глав, разделов и статей. */
|
/** Сохраняет новое расположение глав, разделов и статей. */
|
||||||
@@ -1135,7 +1135,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun assignDefense(
|
fun assignDefense(
|
||||||
@@ -1166,7 +1166,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(Map::class.java)
|
.body(Map::class.java)
|
||||||
}?.get("code").toString()
|
}.get("code").toString()
|
||||||
|
|
||||||
fun resetDefense(
|
fun resetDefense(
|
||||||
token: String,
|
token: String,
|
||||||
@@ -1191,7 +1191,7 @@ class ApiClient(
|
|||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type)
|
.body(type)
|
||||||
} ?: emptyList()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun myDefense(
|
fun myDefense(
|
||||||
|
|||||||
Reference in New Issue
Block a user