feat(academic): курсовые материалы и публичный ALL-контент

This commit is contained in:
2026-10-27 19:01:24 +03:00
parent 4a2daf2fec
commit 43229059d3
15 changed files with 593 additions and 63 deletions

View File

@@ -23,6 +23,7 @@ dependencies {
implementation(project(":libs:security-jwt")) implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support")) testImplementation(project(":libs:test-support"))
testImplementation(libs.springSecurityTest)
} }
springBoot { springBoot {

View File

@@ -0,0 +1,4 @@
package ru.ysu.lms.academic.content
/** Аудитория глобальной главы, доступной без авторизации. */
const val AUDIENCE_ALL = "ALL"

View File

@@ -56,7 +56,15 @@ class ContentAuthoringService(
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) } slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()) ?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0 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( return ChapterTreeNode(
id = ch.id.toString(), id = ch.id.toString(),
title = ch.title, title = ch.title,

View File

@@ -85,10 +85,13 @@ class ContentImporter(
existing?.apply { existing?.apply {
title = p.chapterDir title = p.chapterDir
position = p.chapterPos position = p.chapterPos
// Импорт создаёт/обновляет глобальные главы; курсовые не трогаем.
if (courseId == null && audience == null) audience = AUDIENCE_ALL
} ?: ContentChapter( } ?: ContentChapter(
title = p.chapterDir, title = p.chapterDir,
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()), slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
position = p.chapterPos, position = p.chapterPos,
audience = AUDIENCE_ALL,
) )
state.chapter = chapters.save(entity) state.chapter = chapters.save(entity)
state.touchedChapters.add(state.chapter!!.id) state.touchedChapters.add(state.chapter!!.id)

View File

@@ -2,6 +2,7 @@ package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional 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.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository import ru.ysu.lms.academic.repo.ContentSectionRepository
@@ -99,18 +100,122 @@ class ContentService(
private val articles: ContentArticleRepository, private val articles: ContentArticleRepository,
private val importer: ContentImporter, private val importer: ContentImporter,
) { ) {
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree { fun tree(includeHidden: Boolean = false): ContentTree =
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published } 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 = val sectionList =
sections sections
.findAllByOrderByPositionAscIdAsc() .findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published } .filter { it.chapterId in chapterIds && (includeHidden || it.published) }
.groupBy { it.chapterId } .groupBy { it.chapterId }
val sectionIds = sectionList.values.flatten().mapTo(HashSet()) { it.id }
val articleList = val articleList =
articles articles
.findAllByOrderByPositionAscIdAsc() .findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published } .filter { it.sectionId in sectionIds && (includeHidden || it.published) }
.groupBy { it.sectionId } .groupBy { it.sectionId }
return ContentTree( return ContentTree(
chapterList.map { ch -> 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( private fun <T> requireFound(
value: T?, value: T?,
message: String, message: String,

View File

@@ -72,20 +72,10 @@ class ContentAdminController(
@PathVariable chapter: String, @PathVariable chapter: String,
@PathVariable section: String, @PathVariable section: String,
@PathVariable article: String, @PathVariable article: String,
): ContentPublicController.ContentArticleViewDto { ): ContentPublicController.ContentArticleViewDto =
val a = contentService.findArticle(chapter, section, article, includeHidden = true) contentService
return ContentPublicController.ContentArticleViewDto( .findArticleForAdmin(chapter, section, article)
id = a.id, .toDto()
chapterSlug = a.chapterSlug,
chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
@PutMapping("/chapters/{id}/published") @PutMapping("/chapters/{id}/published")
fun chapterPublished( fun chapterPublished(

View File

@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.content.AssetStorage import ru.ysu.lms.academic.content.AssetStorage
import ru.ysu.lms.academic.content.ContentArticleView
import ru.ysu.lms.academic.content.ContentService import ru.ysu.lms.academic.content.ContentService
/** Публичное чтение учебных материалов — доступно без аутентификации. */ /** Публичное чтение учебных материалов — доступно без аутентификации. */
@@ -25,20 +26,7 @@ class ContentPublicController(
@PathVariable chapter: String, @PathVariable chapter: String,
@PathVariable section: String, @PathVariable section: String,
@PathVariable article: String, @PathVariable article: String,
): ContentArticleViewDto { ): ContentArticleViewDto = contentService.findArticle(chapter, section, article).toDto()
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,
)
}
data class ContentArticleViewDto( data class ContentArticleViewDto(
val id: String, val id: String,
@@ -76,3 +64,16 @@ class ContentPublicController(
return builder.body(FileSystemResource(path)) 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,
)

View File

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

View File

@@ -62,6 +62,17 @@ class CourseController(
return courseService.detail(id) 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") @GetMapping("/{courseId}/groups/{groupId}/students")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")

View File

@@ -63,6 +63,10 @@ class CourseService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun detail(courseId: UUID): CourseDetail = toDetail(findCourse(courseId)) fun detail(courseId: UUID): CourseDetail = toDetail(findCourse(courseId))
@Transactional(readOnly = true)
fun findByCourseNo(courseNo: Long): CourseDetail =
toDetail(courses.findByCourseNo(courseNo) ?: throw NotFoundException("Курс не найден"))
@Transactional @Transactional
fun update( fun update(
courseId: UUID, courseId: UUID,

View File

@@ -11,6 +11,15 @@ interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
fun findBySlug(slug: String): ContentChapter? 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? fun findByTitleIgnoreCase(title: String): ContentChapter?
} }

View File

@@ -9,6 +9,8 @@ import java.util.UUID
interface CourseRepository : JpaRepository<Course, UUID> { interface CourseRepository : JpaRepository<Course, UUID> {
fun findAllByOrderByCourseNoAsc(): List<Course> fun findAllByOrderByCourseNoAsc(): List<Course>
fun findByCourseNo(courseNo: Long): Course?
fun findByTitleIgnoreCase(title: String): Course? fun findByTitleIgnoreCase(title: String): Course?
} }

View File

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

View File

@@ -72,6 +72,7 @@ class ContentImporterTest {
assertThat(result.articles).isEqualTo(1) assertThat(result.articles).isEqualTo(1)
assertThat(savedChapter.captured.title).isEqualTo("Глава") assertThat(savedChapter.captured.title).isEqualTo("Глава")
assertThat(savedChapter.captured.slug).isEqualTo("glava") assertThat(savedChapter.captured.slug).isEqualTo("glava")
assertThat(savedChapter.captured.audience).isEqualTo(AUDIENCE_ALL)
assertThat(savedArticle.captured.title).isEqualTo("Ввод-вывод") assertThat(savedArticle.captured.title).isEqualTo("Ввод-вывод")
assertThat(savedArticle.captured.slug).isEqualTo("c-input") assertThat(savedArticle.captured.slug).isEqualTo("c-input")
} }
@@ -92,5 +93,46 @@ class ContentImporterTest {
assertThat(result.chapters).isEqualTo(1) assertThat(result.chapters).isEqualTo(1)
assertThat(chapterSaves).containsExactly(chapter.id) 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()
} }
} }

View File

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