feat(academic): курсовые материалы и публичный ALL-контент
This commit is contained in:
@@ -23,6 +23,7 @@ dependencies {
|
||||
implementation(project(":libs:security-jwt"))
|
||||
|
||||
testImplementation(project(":libs:test-support"))
|
||||
testImplementation(libs.springSecurityTest)
|
||||
}
|
||||
|
||||
springBoot {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package ru.ysu.lms.academic.content
|
||||
|
||||
/** Аудитория глобальной главы, доступной без авторизации. */
|
||||
const val AUDIENCE_ALL = "ALL"
|
||||
@@ -56,7 +56,15 @@ class ContentAuthoringService(
|
||||
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
||||
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
||||
val ch =
|
||||
chapters.save(
|
||||
ContentChapter(
|
||||
title = title.trim(),
|
||||
slug = cleanSlug,
|
||||
position = maxPos + 1,
|
||||
audience = AUDIENCE_ALL,
|
||||
),
|
||||
)
|
||||
return ChapterTreeNode(
|
||||
id = ch.id.toString(),
|
||||
title = ch.title,
|
||||
|
||||
@@ -85,10 +85,13 @@ class ContentImporter(
|
||||
existing?.apply {
|
||||
title = p.chapterDir
|
||||
position = p.chapterPos
|
||||
// Импорт создаёт/обновляет глобальные главы; курсовые не трогаем.
|
||||
if (courseId == null && audience == null) audience = AUDIENCE_ALL
|
||||
} ?: ContentChapter(
|
||||
title = p.chapterDir,
|
||||
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
|
||||
position = p.chapterPos,
|
||||
audience = AUDIENCE_ALL,
|
||||
)
|
||||
state.chapter = chapters.save(entity)
|
||||
state.touchedChapters.add(state.chapter!!.id)
|
||||
|
||||
@@ -2,6 +2,7 @@ package ru.ysu.lms.academic.content
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.academic.domain.ContentChapter
|
||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
@@ -99,18 +100,122 @@ class ContentService(
|
||||
private val articles: ContentArticleRepository,
|
||||
private val importer: ContentImporter,
|
||||
) {
|
||||
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
|
||||
@Transactional(readOnly = true)
|
||||
fun tree(includeHidden: Boolean = false): ContentTree {
|
||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
|
||||
fun tree(includeHidden: Boolean = false): ContentTree =
|
||||
buildTree(
|
||||
chapters
|
||||
.findAllByOrderByPositionAscIdAsc()
|
||||
.filter {
|
||||
it.courseId == null &&
|
||||
it.audience == AUDIENCE_ALL &&
|
||||
(includeHidden || it.published)
|
||||
},
|
||||
includeHidden = includeHidden,
|
||||
)
|
||||
|
||||
/** Полное дерево (включая скрытые и курсовые главы) — только для админки. */
|
||||
@Transactional(readOnly = true)
|
||||
fun treeAll(): ContentTree = buildTree(chapters.findAllByOrderByPositionAscIdAsc(), includeHidden = true)
|
||||
|
||||
/** Дерево материалов конкретного курса. */
|
||||
@Transactional(readOnly = true)
|
||||
fun courseTree(
|
||||
courseId: UUID,
|
||||
includeHidden: Boolean = false,
|
||||
): ContentTree =
|
||||
buildTree(
|
||||
chapters.findByCourseIdOrderByPositionAscIdAsc(courseId).filter { includeHidden || it.published },
|
||||
includeHidden = includeHidden,
|
||||
)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findArticle(
|
||||
chapterSlug: String,
|
||||
sectionSlug: String,
|
||||
articleSlug: String,
|
||||
includeHidden: Boolean = false,
|
||||
): ContentArticleView {
|
||||
val ch = requireFound(chapters.findBySlugAndCourseIdIsNull(chapterSlug), "Глава не найдена")
|
||||
requirePublicChapter(ch, includeHidden)
|
||||
return articleView(ch, sectionSlug, articleSlug, includeHidden)
|
||||
}
|
||||
|
||||
/** Просмотр статьи админом: глобальные и курсовые главы без учёта публикации. */
|
||||
@Transactional(readOnly = true)
|
||||
fun findArticleForAdmin(
|
||||
chapterSlug: String,
|
||||
sectionSlug: String,
|
||||
articleSlug: String,
|
||||
): ContentArticleView {
|
||||
// Сначала глобальная глава: findBySlug может упасть на коллизии slug между курсами.
|
||||
val ch =
|
||||
requireFound(
|
||||
chapters.findBySlugAndCourseIdIsNull(chapterSlug) ?: chapters.findBySlug(chapterSlug),
|
||||
"Глава не найдена",
|
||||
)
|
||||
return articleView(ch, sectionSlug, articleSlug, includeHidden = true)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findCourseArticle(
|
||||
courseId: UUID,
|
||||
chapterSlug: String,
|
||||
sectionSlug: String,
|
||||
articleSlug: String,
|
||||
includeHidden: Boolean = false,
|
||||
): ContentArticleView {
|
||||
val ch = requireFound(chapters.findByCourseIdAndSlug(courseId, chapterSlug), "Глава не найдена")
|
||||
requireVisible(ch.published, includeHidden, "Глава скрыта")
|
||||
return articleView(ch, sectionSlug, articleSlug, includeHidden)
|
||||
}
|
||||
|
||||
private fun articleView(
|
||||
ch: ContentChapter,
|
||||
sectionSlug: String,
|
||||
articleSlug: String,
|
||||
includeHidden: Boolean,
|
||||
): ContentArticleView {
|
||||
val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
|
||||
requireVisible(sec.published, includeHidden, "Раздел скрыт")
|
||||
val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
|
||||
requireVisible(a.published, includeHidden, "Статья скрыта")
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug,
|
||||
sectionTitle = sec.title,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
published = a.published,
|
||||
)
|
||||
}
|
||||
|
||||
private fun requirePublicChapter(
|
||||
ch: ContentChapter,
|
||||
includeHidden: Boolean,
|
||||
) {
|
||||
requireVisible(ch.published, includeHidden, "Глава скрыта")
|
||||
if (!includeHidden && ch.audience != AUDIENCE_ALL) throw NotFoundException("Глава недоступна")
|
||||
}
|
||||
|
||||
private fun buildTree(
|
||||
chapterList: List<ContentChapter>,
|
||||
includeHidden: Boolean,
|
||||
): ContentTree {
|
||||
val chapterIds = chapterList.mapTo(HashSet()) { it.id }
|
||||
val sectionList =
|
||||
sections
|
||||
.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.filter { it.chapterId in chapterIds && (includeHidden || it.published) }
|
||||
.groupBy { it.chapterId }
|
||||
val sectionIds = sectionList.values.flatten().mapTo(HashSet()) { it.id }
|
||||
val articleList =
|
||||
articles
|
||||
.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.filter { it.sectionId in sectionIds && (includeHidden || it.published) }
|
||||
.groupBy { it.sectionId }
|
||||
return ContentTree(
|
||||
chapterList.map { ch ->
|
||||
@@ -139,36 +244,6 @@ class ContentService(
|
||||
)
|
||||
}
|
||||
|
||||
/** Полное дерево (включая скрытые) — только для админки. */
|
||||
@Transactional(readOnly = true)
|
||||
fun treeAll(): ContentTree = tree(includeHidden = true)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findArticle(
|
||||
chapterSlug: String,
|
||||
sectionSlug: String,
|
||||
articleSlug: String,
|
||||
includeHidden: Boolean = false,
|
||||
): ContentArticleView {
|
||||
val ch = requireFound(chapters.findBySlug(chapterSlug), "Глава не найдена")
|
||||
requireVisible(ch.published, includeHidden, "Глава скрыта")
|
||||
val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
|
||||
requireVisible(sec.published, includeHidden, "Раздел скрыт")
|
||||
val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
|
||||
requireVisible(a.published, includeHidden, "Статья скрыта")
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug,
|
||||
sectionTitle = sec.title,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
published = a.published,
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> requireFound(
|
||||
value: T?,
|
||||
message: String,
|
||||
|
||||
@@ -72,20 +72,10 @@ class ContentAdminController(
|
||||
@PathVariable chapter: String,
|
||||
@PathVariable section: String,
|
||||
@PathVariable article: String,
|
||||
): ContentPublicController.ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
|
||||
return ContentPublicController.ContentArticleViewDto(
|
||||
id = a.id,
|
||||
chapterSlug = a.chapterSlug,
|
||||
chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug,
|
||||
sectionTitle = a.sectionTitle,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
published = a.published,
|
||||
)
|
||||
}
|
||||
): ContentPublicController.ContentArticleViewDto =
|
||||
contentService
|
||||
.findArticleForAdmin(chapter, section, article)
|
||||
.toDto()
|
||||
|
||||
@PutMapping("/chapters/{id}/published")
|
||||
fun chapterPublished(
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import ru.ysu.lms.academic.content.AssetStorage
|
||||
import ru.ysu.lms.academic.content.ContentArticleView
|
||||
import ru.ysu.lms.academic.content.ContentService
|
||||
|
||||
/** Публичное чтение учебных материалов — доступно без аутентификации. */
|
||||
@@ -25,20 +26,7 @@ class ContentPublicController(
|
||||
@PathVariable chapter: String,
|
||||
@PathVariable section: String,
|
||||
@PathVariable article: String,
|
||||
): ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article)
|
||||
return ContentArticleViewDto(
|
||||
id = a.id,
|
||||
chapterSlug = a.chapterSlug,
|
||||
chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug,
|
||||
sectionTitle = a.sectionTitle,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
published = a.published,
|
||||
)
|
||||
}
|
||||
): ContentArticleViewDto = contentService.findArticle(chapter, section, article).toDto()
|
||||
|
||||
data class ContentArticleViewDto(
|
||||
val id: String,
|
||||
@@ -76,3 +64,16 @@ class ContentPublicController(
|
||||
return builder.body(FileSystemResource(path))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ContentArticleView.toDto(): ContentPublicController.ContentArticleViewDto =
|
||||
ContentPublicController.ContentArticleViewDto(
|
||||
id = id,
|
||||
chapterSlug = chapterSlug,
|
||||
chapterTitle = chapterTitle,
|
||||
sectionSlug = sectionSlug,
|
||||
sectionTitle = sectionTitle,
|
||||
slug = slug,
|
||||
title = title,
|
||||
bodyMd = bodyMd,
|
||||
published = published,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package ru.ysu.lms.academic.controller
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import ru.ysu.lms.academic.content.ContentService
|
||||
import ru.ysu.lms.academic.content.ContentTree
|
||||
import ru.ysu.lms.academic.course.CourseAccessService
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Материалы курса. Доступ проверяется по курсовой области видимости:
|
||||
* администратор — любой курс, преподаватель — назначенный, студент — курс своей группы.
|
||||
* Скрытые главы/разделы/статьи видят только администратор и преподаватель курса.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/courses/{courseId}/content")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
class CourseContentController(
|
||||
private val contentService: ContentService,
|
||||
private val accessService: CourseAccessService,
|
||||
) {
|
||||
@GetMapping("/tree")
|
||||
fun tree(
|
||||
@PathVariable courseId: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ContentTree {
|
||||
accessService.requireCourseAccess(courseId, jwt)
|
||||
return contentService.courseTree(courseId, includeHidden = canSeeHidden(jwt))
|
||||
}
|
||||
|
||||
@GetMapping("/{chapter}/{section}/{article}")
|
||||
fun article(
|
||||
@PathVariable courseId: UUID,
|
||||
@PathVariable chapter: String,
|
||||
@PathVariable section: String,
|
||||
@PathVariable article: String,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ContentPublicController.ContentArticleViewDto {
|
||||
accessService.requireCourseAccess(courseId, jwt)
|
||||
return contentService
|
||||
.findCourseArticle(courseId, chapter, section, article, includeHidden = canSeeHidden(jwt))
|
||||
.toDto()
|
||||
}
|
||||
|
||||
private fun canSeeHidden(jwt: Jwt): Boolean =
|
||||
jwt.getClaimAsString("role") in setOf(CourseAccessService.ROLE_ADMIN, CourseAccessService.ROLE_TEACHER)
|
||||
}
|
||||
@@ -62,6 +62,17 @@ class CourseController(
|
||||
return courseService.detail(id)
|
||||
}
|
||||
|
||||
/** Курс по человекочитаемому номеру из URL (`course_no`). */
|
||||
@GetMapping("/by-no/{courseNo}")
|
||||
fun detailByNo(
|
||||
@PathVariable courseNo: Long,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): CourseDetail {
|
||||
val course = courseService.findByCourseNo(courseNo)
|
||||
accessService.requireCourseAccess(UUID.fromString(course.id), jwt)
|
||||
return course
|
||||
}
|
||||
|
||||
/** Состав группы, активно привязанной к курсу. */
|
||||
@GetMapping("/{courseId}/groups/{groupId}/students")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
|
||||
@@ -63,6 +63,10 @@ class CourseService(
|
||||
@Transactional(readOnly = true)
|
||||
fun detail(courseId: UUID): CourseDetail = toDetail(findCourse(courseId))
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findByCourseNo(courseNo: Long): CourseDetail =
|
||||
toDetail(courses.findByCourseNo(courseNo) ?: throw NotFoundException("Курс не найден"))
|
||||
|
||||
@Transactional
|
||||
fun update(
|
||||
courseId: UUID,
|
||||
|
||||
@@ -11,6 +11,15 @@ interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
|
||||
|
||||
fun findBySlug(slug: String): ContentChapter?
|
||||
|
||||
fun findBySlugAndCourseIdIsNull(slug: String): ContentChapter?
|
||||
|
||||
fun findByCourseIdOrderByPositionAscIdAsc(courseId: UUID): List<ContentChapter>
|
||||
|
||||
fun findByCourseIdAndSlug(
|
||||
courseId: UUID,
|
||||
slug: String,
|
||||
): ContentChapter?
|
||||
|
||||
fun findByTitleIgnoreCase(title: String): ContentChapter?
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import java.util.UUID
|
||||
interface CourseRepository : JpaRepository<Course, UUID> {
|
||||
fun findAllByOrderByCourseNoAsc(): List<Course>
|
||||
|
||||
fun findByCourseNo(courseNo: Long): Course?
|
||||
|
||||
fun findByTitleIgnoreCase(title: String): Course?
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Глобальные главы (course_id IS NULL) должны быть публично доступны.
|
||||
-- Backfill: старые глобальные главы без аудитории получают ALL.
|
||||
-- Курсовые главы остаются с audience = NULL: ограничение chapters_course_audience_check.
|
||||
UPDATE academic.chapters
|
||||
SET audience = 'ALL'
|
||||
WHERE course_id IS NULL
|
||||
AND audience IS NULL;
|
||||
@@ -72,6 +72,7 @@ class ContentImporterTest {
|
||||
assertThat(result.articles).isEqualTo(1)
|
||||
assertThat(savedChapter.captured.title).isEqualTo("Глава")
|
||||
assertThat(savedChapter.captured.slug).isEqualTo("glava")
|
||||
assertThat(savedChapter.captured.audience).isEqualTo(AUDIENCE_ALL)
|
||||
assertThat(savedArticle.captured.title).isEqualTo("Ввод-вывод")
|
||||
assertThat(savedArticle.captured.slug).isEqualTo("c-input")
|
||||
}
|
||||
@@ -92,5 +93,46 @@ class ContentImporterTest {
|
||||
|
||||
assertThat(result.chapters).isEqualTo(1)
|
||||
assertThat(chapterSaves).containsExactly(chapter.id)
|
||||
assertThat(chapter.audience).isEqualTo(AUDIENCE_ALL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not overwrite explicit global audience`() {
|
||||
val importer = importer()
|
||||
val chapter =
|
||||
ContentChapter(
|
||||
title = "Глава",
|
||||
slug = "glava",
|
||||
position = 1,
|
||||
audience = "STUDENTS",
|
||||
)
|
||||
val section = ContentSection(chapterId = chapter.id, title = "Раздел", slug = "razdel", position = 1)
|
||||
every { chapters.findByTitleIgnoreCase("Глава") } returns chapter
|
||||
every { sections.findByChapterIdAndTitleIgnoreCase(chapter.id, "Раздел") } returns section
|
||||
|
||||
importer.importZip("content.zip", articleZip("Тема", "c-topic"), sync = false)
|
||||
|
||||
assertThat(chapter.audience).isEqualTo("STUDENTS")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not give ALL audience to course chapter`() {
|
||||
val importer = importer()
|
||||
val courseId = UUID.randomUUID()
|
||||
val chapter =
|
||||
ContentChapter(
|
||||
title = "Глава",
|
||||
slug = "glava",
|
||||
position = 1,
|
||||
courseId = courseId,
|
||||
)
|
||||
val section = ContentSection(chapterId = chapter.id, title = "Раздел", slug = "razdel", position = 1)
|
||||
every { chapters.findByTitleIgnoreCase("Глава") } returns chapter
|
||||
every { sections.findByChapterIdAndTitleIgnoreCase(chapter.id, "Раздел") } returns section
|
||||
|
||||
importer.importZip("content.zip", articleZip("Тема", "c-topic"), sync = false)
|
||||
|
||||
assertThat(chapter.courseId).isEqualTo(courseId)
|
||||
assertThat(chapter.audience).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package ru.ysu.lms.academic.content
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
|
||||
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
import org.springframework.test.context.DynamicPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.context.WebApplicationContext
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer
|
||||
import ru.ysu.lms.academic.course.CourseCreateRequest
|
||||
import ru.ysu.lms.academic.course.CourseGroupRequest
|
||||
import ru.ysu.lms.academic.course.CourseService
|
||||
import ru.ysu.lms.academic.course.CourseTeacherRequest
|
||||
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.identity.IdentityClient
|
||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
import java.nio.file.Files
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Курсовые материалы: публичное дерево не отдаёт главы курса, а доступ к материалам
|
||||
* курса проверяется по роли и назначению. Внешний identity подменён.
|
||||
*/
|
||||
@Testcontainers
|
||||
@SpringBootTest
|
||||
@Transactional
|
||||
class CourseContentIntegrationTest {
|
||||
companion object {
|
||||
@Container
|
||||
@ServiceConnection
|
||||
@JvmStatic
|
||||
val postgres = PostgreSQLContainer("postgres:18")
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun assetsDir(registry: DynamicPropertyRegistry) {
|
||||
registry.add("lms.content.assets-dir") { Files.createTempDirectory("lms-assets").toString() }
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private lateinit var context: WebApplicationContext
|
||||
|
||||
@Autowired
|
||||
private lateinit var contentService: ContentService
|
||||
|
||||
@Autowired
|
||||
private lateinit var authoring: ContentAuthoringService
|
||||
|
||||
@Autowired
|
||||
private lateinit var courseService: CourseService
|
||||
|
||||
@Autowired
|
||||
private lateinit var chapters: ContentChapterRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var sections: ContentSectionRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var articles: ContentArticleRepository
|
||||
|
||||
@MockitoBean
|
||||
private lateinit var identity: IdentityClient
|
||||
|
||||
private val mockMvc: MockMvc by lazy {
|
||||
MockMvcBuilders
|
||||
.webAppContextSetup(context)
|
||||
.apply<DefaultMockMvcBuilder>(springSecurity())
|
||||
.build()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public tree contains only global chapters with ALL audience`() {
|
||||
val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL")
|
||||
seedChapter(title = "Только студентам", courseId = null, audience = "STUDENTS")
|
||||
val course = newCourse()
|
||||
val courseChapter = seedChapter(title = "Курсовая", courseId = course.id, audience = null)
|
||||
|
||||
assertThat(courseChapter.audience).isNull()
|
||||
assertThat(contentService.tree().chapters.map { it.slug }).containsExactly(global.slug)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/v1/content/tree"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.chapters.length()").value(1))
|
||||
.andExpect(jsonPath("$.chapters[0].slug").value(global.slug))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newly created global chapter gets ALL audience and appears in public tree`() {
|
||||
val node = authoring.createChapter("Публичная глава ${UUID.randomUUID()}", slug = null)
|
||||
val saved = chapters.findById(UUID.fromString(node.id)).orElseThrow()
|
||||
|
||||
assertThat(saved.courseId).isNull()
|
||||
assertThat(saved.audience).isEqualTo(AUDIENCE_ALL)
|
||||
assertThat(contentService.tree().chapters.map { it.slug }).contains(node.slug)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anonymous cannot read course content`() {
|
||||
val course = newCourse()
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/v1/courses/${course.id}/content/tree"))
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `course chapter is not exposed through public article endpoint`() {
|
||||
val course = newCourse()
|
||||
val material = seedMaterial(courseId = course.id, published = true)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/v1/content/${material.chapter.slug}/${material.section.slug}/${material.article.slug}"),
|
||||
).andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin preview still includes course chapters and hidden articles`() {
|
||||
val course = newCourse()
|
||||
val material = seedMaterial(courseId = course.id, published = false)
|
||||
|
||||
assertThat(contentService.treeAll().chapters.map { it.slug }).contains(material.chapter.slug)
|
||||
assertThat(contentService.findArticleForAdmin(material.chapter.slug, material.section.slug, material.article.slug).title)
|
||||
.isEqualTo(material.article.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher of course sees published and hidden materials`() {
|
||||
val course = newCourse()
|
||||
val teacherId = UUID.randomUUID()
|
||||
courseService.assignTeacher(course.id, CourseTeacherRequest(teacherId.toString()))
|
||||
val published = seedMaterial(courseId = course.id, published = true)
|
||||
val hidden = seedMaterial(courseId = course.id, published = false)
|
||||
|
||||
val tree = contentService.courseTree(course.id, includeHidden = true)
|
||||
assertThat(tree.chapters.map { it.slug }).containsExactly(published.chapter.slug, hidden.chapter.slug)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/v1/courses/${course.id}/content/tree")
|
||||
.with(jwtFor(teacherId, "TEACHER")),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.chapters.length()").value(2))
|
||||
.andExpect(jsonPath("$.chapters[1].published").value(false))
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get(
|
||||
"/api/v1/courses/${course.id}/content/" +
|
||||
"${hidden.chapter.slug}/${hidden.section.slug}/${hidden.article.slug}",
|
||||
).with(jwtFor(teacherId, "TEACHER")),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.title").value(hidden.article.title))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `student of course sees only published materials`() {
|
||||
val course = newCourse()
|
||||
val groupId = UUID.randomUUID()
|
||||
courseService.attachGroup(course.id, CourseGroupRequest(groupId.toString()))
|
||||
val studentId = UUID.randomUUID()
|
||||
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
|
||||
val published = seedMaterial(courseId = course.id, published = true)
|
||||
val hidden = seedMaterial(courseId = course.id, published = false)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/v1/courses/${course.id}/content/tree")
|
||||
.with(jwtFor(studentId, "STUDENT")),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.chapters.length()").value(1))
|
||||
.andExpect(jsonPath("$.chapters[0].slug").value(published.chapter.slug))
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get(
|
||||
"/api/v1/courses/${course.id}/content/" +
|
||||
"${hidden.chapter.slug}/${hidden.section.slug}/${hidden.article.slug}",
|
||||
).with(jwtFor(studentId, "STUDENT")),
|
||||
).andExpect(status().isNotFound)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get(
|
||||
"/api/v1/courses/${course.id}/content/" +
|
||||
"${published.chapter.slug}/${published.section.slug}/${published.article.slug}",
|
||||
).with(jwtFor(studentId, "STUDENT")),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.title").value(published.article.title))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foreign teacher is forbidden for course content and course by number`() {
|
||||
val course = newCourse()
|
||||
val foreignTeacher = UUID.randomUUID()
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/v1/courses/${course.id}/content/tree")
|
||||
.with(jwtFor(foreignTeacher, "TEACHER")),
|
||||
).andExpect(status().isForbidden)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/v1/courses/by-no/${course.courseNo}")
|
||||
.with(jwtFor(foreignTeacher, "TEACHER")),
|
||||
).andExpect(status().isForbidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `course is available by number to its teacher`() {
|
||||
val course = newCourse()
|
||||
val teacherId = UUID.randomUUID()
|
||||
courseService.assignTeacher(course.id, CourseTeacherRequest(teacherId.toString()))
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/v1/courses/by-no/${course.courseNo}")
|
||||
.with(jwtFor(teacherId, "TEACHER")),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.id").value(course.id.toString()))
|
||||
.andExpect(jsonPath("$.courseNo").value(course.courseNo))
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/v1/courses/by-no/${course.courseNo}"))
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
|
||||
private fun newCourse(): TestCourse {
|
||||
val detail =
|
||||
courseService.create(
|
||||
CourseCreateRequest("Курс материалов ${UUID.randomUUID()}"),
|
||||
UUID.randomUUID(),
|
||||
)
|
||||
return TestCourse(id = UUID.fromString(detail.id), courseNo = detail.courseNo)
|
||||
}
|
||||
|
||||
private fun seedChapter(
|
||||
title: String,
|
||||
courseId: UUID?,
|
||||
audience: String?,
|
||||
published: Boolean = true,
|
||||
): ContentChapter =
|
||||
chapters.saveAndFlush(
|
||||
ContentChapter(
|
||||
title = title,
|
||||
slug = "chapter-${UUID.randomUUID()}",
|
||||
position = 1,
|
||||
published = published,
|
||||
courseId = courseId,
|
||||
audience = audience,
|
||||
),
|
||||
)
|
||||
|
||||
private fun seedMaterial(
|
||||
courseId: UUID?,
|
||||
published: Boolean,
|
||||
): SeededMaterial {
|
||||
val chapter = seedChapter(title = "Глава ${UUID.randomUUID()}", courseId = courseId, audience = null, published = published)
|
||||
val section =
|
||||
sections.saveAndFlush(
|
||||
ContentSection(
|
||||
chapterId = chapter.id,
|
||||
title = "Раздел ${UUID.randomUUID()}",
|
||||
slug = "section-${UUID.randomUUID()}",
|
||||
position = 1,
|
||||
published = published,
|
||||
),
|
||||
)
|
||||
val article =
|
||||
articles.saveAndFlush(
|
||||
ContentArticle(
|
||||
sectionId = section.id,
|
||||
title = "Статья ${UUID.randomUUID()}",
|
||||
slug = "article-${UUID.randomUUID()}",
|
||||
bodyMd = "Текст",
|
||||
position = 1,
|
||||
published = published,
|
||||
),
|
||||
)
|
||||
return SeededMaterial(chapter, section, article)
|
||||
}
|
||||
|
||||
private fun jwtFor(
|
||||
subject: UUID,
|
||||
role: String,
|
||||
) = jwt().jwt { it.subject(subject.toString()).claim("role", role) }
|
||||
|
||||
private data class SeededMaterial(
|
||||
val chapter: ContentChapter,
|
||||
val section: ContentSection,
|
||||
val article: ContentArticle,
|
||||
)
|
||||
|
||||
private data class TestCourse(
|
||||
val id: UUID,
|
||||
val courseNo: Long,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user