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 09a08d7..dca6163 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 @@ -148,10 +148,12 @@ class ContentService( sectionSlug: String, articleSlug: String, ): ContentArticleView { - // Сначала глобальная глава: findBySlug может упасть на коллизии slug между курсами. + // Сначала глобальная глава, затем первая курсовая по id: slug может повторяться + // в разных курсах, а findBySlug на дублях бросает IncorrectResultSizeDataAccessException. val ch = requireFound( - chapters.findBySlugAndCourseIdIsNull(chapterSlug) ?: chapters.findBySlug(chapterSlug), + chapters.findBySlugAndCourseIdIsNull(chapterSlug) + ?: chapters.findFirstBySlugOrderByIdAsc(chapterSlug), "Глава не найдена", ) return articleView(ch, sectionSlug, articleSlug, includeHidden = true) 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 20aef5b..1ce4849 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,8 @@ interface ContentChapterRepository : JpaRepository { fun findBySlug(slug: String): ContentChapter? + fun findFirstBySlugOrderByIdAsc(slug: String): ContentChapter? + fun findBySlugAndCourseIdIsNull(slug: String): ContentChapter? fun findByCourseIdOrderByPositionAscIdAsc(courseId: UUID): List 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 index 0748af1..9cc137f 100644 --- 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 @@ -56,6 +56,9 @@ class CourseContentIntegrationTest { fun assetsDir(registry: DynamicPropertyRegistry) { registry.add("lms.content.assets-dir") { Files.createTempDirectory("lms-assets").toString() } } + + private const val PREVIEW_SECTION_SLUG = "preview-section" + private const val PREVIEW_ARTICLE_SLUG = "preview-article" } @Autowired @@ -248,6 +251,20 @@ class CourseContentIntegrationTest { .andExpect(status().isUnauthorized) } + @Test + fun `admin preview resolves same-slug course chapters deterministically`() { + val sharedSlug = "shared-${UUID.randomUUID()}" + val first = seedChapter(title = "Первая курсовая", courseId = newCourse().id, audience = null, slug = sharedSlug) + val second = seedChapter(title = "Вторая курсовая", courseId = newCourse().id, audience = null, slug = sharedSlug) + val firstArticle = seedPreviewArticle(first, "Статья первой главы") + val secondArticle = seedPreviewArticle(second, "Статья второй главы") + val expectedTitle = if (first.id < second.id) firstArticle.title else secondArticle.title + + val view = contentService.findArticleForAdmin(sharedSlug, PREVIEW_SECTION_SLUG, PREVIEW_ARTICLE_SLUG) + + assertThat(view.title).isEqualTo(expectedTitle) + } + private fun newCourse(): TestCourse { val detail = courseService.create( @@ -262,11 +279,12 @@ class CourseContentIntegrationTest { courseId: UUID?, audience: String?, published: Boolean = true, + slug: String = "chapter-${UUID.randomUUID()}", ): ContentChapter = chapters.saveAndFlush( ContentChapter( title = title, - slug = "chapter-${UUID.randomUUID()}", + slug = slug, position = 1, published = published, courseId = courseId, @@ -274,6 +292,30 @@ class CourseContentIntegrationTest { ), ) + private fun seedPreviewArticle( + chapter: ContentChapter, + title: String, + ): ContentArticle { + val section = + sections.saveAndFlush( + ContentSection( + chapterId = chapter.id, + title = "Раздел", + slug = PREVIEW_SECTION_SLUG, + position = 1, + ), + ) + return articles.saveAndFlush( + ContentArticle( + sectionId = section.id, + title = title, + slug = PREVIEW_ARTICLE_SLUG, + bodyMd = "Текст", + position = 1, + ), + ) + } + private fun seedMaterial( courseId: UUID?, published: Boolean,