Add ContentImporter tests; drop redundant elvis; document lint/test commands

This commit is contained in:
2026-09-27 22:17:59 +03:00
parent a021bdb904
commit 2e99fd926c
3 changed files with 128 additions and 18 deletions

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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<MeAssignmentDto> {
@@ -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<GradeDto> = 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(