fix(academic): детерминированный предпросмотр при совпадении slug

This commit is contained in:
2026-10-27 19:15:15 +03:00
parent 60a318539c
commit 520a61085a
3 changed files with 49 additions and 3 deletions

View File

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

View File

@@ -11,6 +11,8 @@ interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
fun findBySlug(slug: String): ContentChapter?
fun findFirstBySlugOrderByIdAsc(slug: String): ContentChapter?
fun findBySlugAndCourseIdIsNull(slug: String): ContentChapter?
fun findByCourseIdOrderByPositionAscIdAsc(courseId: UUID): List<ContentChapter>

View File

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