From 43229059d303ed58ac62867949b83dcfb231208c Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 27 Oct 2026 19:01:24 +0300 Subject: [PATCH] =?UTF-8?q?feat(academic):=20=D0=BA=D1=83=D1=80=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=B0=D1=82=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D1=8B=20=D0=B8=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20ALL-=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/academic-service/build.gradle.kts | 1 + .../lms/academic/content/ContentAudience.kt | 4 + .../content/ContentAuthoringService.kt | 10 +- .../lms/academic/content/ContentImporter.kt | 3 + .../lms/academic/content/ContentService.kt | 143 ++++++-- .../controller/ContentAdminController.kt | 18 +- .../controller/ContentPublicController.kt | 29 +- .../controller/CourseContentController.kt | 52 +++ .../academic/controller/CourseController.kt | 11 + .../ysu/lms/academic/course/CourseService.kt | 4 + .../lms/academic/repo/ContentRepositories.kt | 9 + .../lms/academic/repo/CourseRepositories.kt | 2 + .../db/migration/V11__global_audience_all.sql | 7 + .../academic/content/ContentImporterTest.kt | 42 +++ .../content/CourseContentIntegrationTest.kt | 321 ++++++++++++++++++ 15 files changed, 593 insertions(+), 63 deletions(-) create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseContentController.kt create mode 100644 services/academic-service/src/main/resources/db/migration/V11__global_audience_all.sql create mode 100644 services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt diff --git a/services/academic-service/build.gradle.kts b/services/academic-service/build.gradle.kts index b928714..e8d91ee 100644 --- a/services/academic-service/build.gradle.kts +++ b/services/academic-service/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(project(":libs:security-jwt")) testImplementation(project(":libs:test-support")) + testImplementation(libs.springSecurityTest) } springBoot { diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt new file mode 100644 index 0000000..2c7d86d --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt @@ -0,0 +1,4 @@ +package ru.ysu.lms.academic.content + +/** Аудитория глобальной главы, доступной без авторизации. */ +const val AUDIENCE_ALL = "ALL" diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt index 08fee22..a8bfc46 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt @@ -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, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt index 21a460c..cdaebc5 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt @@ -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) diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt index 448c0b4..09a08d7 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt @@ -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, + 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 requireFound( value: T?, message: String, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt index ea0dd2e..44898f0 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt @@ -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( diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt index 2b3749c..f38f19c 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt @@ -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, + ) diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseContentController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseContentController.kt new file mode 100644 index 0000000..6a9dca6 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseContentController.kt @@ -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) +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt index f2f5772..508d74e 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt @@ -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()") diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseService.kt index 20aaef4..8bde081 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseService.kt @@ -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, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt index 37f26cf..20aef5b 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt @@ -11,6 +11,15 @@ interface ContentChapterRepository : JpaRepository { fun findBySlug(slug: String): ContentChapter? + fun findBySlugAndCourseIdIsNull(slug: String): ContentChapter? + + fun findByCourseIdOrderByPositionAscIdAsc(courseId: UUID): List + + fun findByCourseIdAndSlug( + courseId: UUID, + slug: String, + ): ContentChapter? + fun findByTitleIgnoreCase(title: String): ContentChapter? } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt index f260536..b02e997 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt @@ -9,6 +9,8 @@ import java.util.UUID interface CourseRepository : JpaRepository { fun findAllByOrderByCourseNoAsc(): List + fun findByCourseNo(courseNo: Long): Course? + fun findByTitleIgnoreCase(title: String): Course? } diff --git a/services/academic-service/src/main/resources/db/migration/V11__global_audience_all.sql b/services/academic-service/src/main/resources/db/migration/V11__global_audience_all.sql new file mode 100644 index 0000000..6887434 --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V11__global_audience_all.sql @@ -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; diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt index 96dd7fd..1b97732 100644 --- a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt @@ -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() } } diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt new file mode 100644 index 0000000..0748af1 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/CourseContentIntegrationTest.kt @@ -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(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, + ) +}