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`, Порты по умолчанию: 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

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)) .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(