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`,
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
.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(
|
||||
|
||||
Reference in New Issue
Block a user