test(ldd): конкурентность, нагрузка и release gate
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
# должен разрешать privileged-сервисы (docker:dind) и сеть до docker:2375.
|
||||
stages:
|
||||
- check
|
||||
- release-evidence
|
||||
|
||||
variables:
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
|
||||
@@ -31,3 +32,31 @@ check:
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
|
||||
# Ручной release gate: runtime, mutation и browser evidence.
|
||||
# Нужен privileged docker:dind; Chromium и Node ставятся в before_script.
|
||||
release-evidence:
|
||||
stage: release-evidence
|
||||
image: eclipse-temurin:21-jdk
|
||||
services:
|
||||
- name: docker:27-dind
|
||||
alias: docker
|
||||
before_script:
|
||||
- apt-get update
|
||||
- apt-get install -y nodejs npm
|
||||
- cd e2e && npm ci && npx playwright install --with-deps chromium && cd ..
|
||||
cache:
|
||||
key: "gradle-$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- .gradle/caches
|
||||
- .gradle/wrapper
|
||||
script:
|
||||
- ./scripts/ldd-release.sh
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- build/evidence/
|
||||
- build/reports/evidence/
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
when: manual
|
||||
|
||||
6
e2e/fixtures/main.c
Normal file
6
e2e/fixtures/main.c
Normal file
@@ -0,0 +1,6 @@
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
puts("LDD e2e");
|
||||
return 0;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ type Seeded = {
|
||||
courseNo: number;
|
||||
courseTitle: string;
|
||||
groupId: string;
|
||||
labId: string;
|
||||
groupName: string;
|
||||
studentFullName: string;
|
||||
studentLogin: string;
|
||||
@@ -102,7 +103,7 @@ test.beforeAll(async ({ playwright }) => {
|
||||
await createWithToken(api, adminToken, "post", `/api/v1/courses/${course.id}/groups`, {
|
||||
groupId: group.id,
|
||||
});
|
||||
await createWithToken(api, adminToken, "post", "/api/v1/labs", {
|
||||
const lab = await createWithToken(api, adminToken, "post", "/api/v1/labs", {
|
||||
number: 1,
|
||||
title: "E2E Lab",
|
||||
status: "published",
|
||||
@@ -126,6 +127,7 @@ test.beforeAll(async ({ playwright }) => {
|
||||
courseTitle: String(course.title),
|
||||
groupId: String(group.id),
|
||||
groupName: String(group.name),
|
||||
labId: String(lab.id),
|
||||
studentFullName: String(student.fullName),
|
||||
studentLogin: studentNo,
|
||||
studentPassword: "e2e-student-456",
|
||||
@@ -186,3 +188,19 @@ test("anonymous and student cannot reach admin pages", async ({ page }) => {
|
||||
await page.goto(`/learn/course/${seeded.courseNo}`);
|
||||
await expect(page.locator("body")).toContainText(seeded.courseTitle);
|
||||
});
|
||||
|
||||
test("student submits a solution and mobile teacher page renders", async ({ page }) => {
|
||||
await login(page, seeded.studentLogin, seeded.studentPassword, "**/student");
|
||||
await page.goto(`/student/labs/${seeded.labId}`);
|
||||
await page.setInputFiles('input[name="files"]', "fixtures/main.c");
|
||||
await page.getByRole("button", { name: "Отправить решение" }).click();
|
||||
await expect(page.locator("body")).toContainText("Решение отправлено");
|
||||
await expect(page.locator("body")).toContainText("main.c");
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await login(page, seeded.teacherLogin, seeded.teacherPassword, "**/teacher");
|
||||
await page.goto(`/teacher/courses/${seeded.courseId}`);
|
||||
await expect(page.locator("h1")).toContainText(seeded.courseTitle);
|
||||
await expect(page.locator("body")).toContainText(seeded.groupName);
|
||||
});
|
||||
|
||||
31
scripts/ldd-release.sh
Executable file
31
scripts/ldd-release.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Полный LDD release gate: тесты, линтеры, upgrade, mutation, runtime и browser E2E.
|
||||
# Требует Docker, Java 21, Node.js/npm и установленный Chromium Playwright.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
log() { echo; echo ">> $*"; }
|
||||
|
||||
log "1/6 Тесты, линтеры и evidence gate"
|
||||
./gradlew test ktlintCheck detekt evidenceCheck --console=plain
|
||||
|
||||
log "2/6 Upgrade с master-схемы"
|
||||
./scripts/ldd-migration-upgrade.sh
|
||||
|
||||
log "3/6 Mutation testing"
|
||||
./scripts/ldd-mutation.sh
|
||||
|
||||
log "4/6 Runtime smoke"
|
||||
./scripts/ldd-runtime.sh
|
||||
|
||||
log "5/6 Browser E2E"
|
||||
./scripts/ldd-browser.sh
|
||||
|
||||
log "6/6 Финальный evidence gate"
|
||||
./gradlew evidenceCheck --console=plain
|
||||
|
||||
echo
|
||||
echo "LDD release evidence готов:"
|
||||
echo " build/evidence/"
|
||||
echo " build/reports/evidence/index.md"
|
||||
@@ -78,8 +78,11 @@ mkdir -p "$(dirname "$EVIDENCE")"
|
||||
|
||||
log "Проверяю HTTP-сценарии..."
|
||||
python3 - "$EVIDENCE" <<'PY'
|
||||
import concurrent.futures
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
@@ -216,6 +219,22 @@ try:
|
||||
token=admin_token,
|
||||
detail="create lab",
|
||||
)
|
||||
student = expect(
|
||||
"runtime.course_flow",
|
||||
"POST",
|
||||
gateway + "/api/v1/groups/" + group["id"] + "/students",
|
||||
200,
|
||||
data={
|
||||
"studentNo": "ldd-student-" + suffix,
|
||||
"lastName": "Тестов",
|
||||
"firstName": "Студент",
|
||||
"middleName": "",
|
||||
"basis": "бюджет",
|
||||
"note": "",
|
||||
},
|
||||
token=admin_token,
|
||||
detail="create student",
|
||||
)
|
||||
teacher_token = expect(
|
||||
"runtime.course_flow",
|
||||
"POST",
|
||||
@@ -283,6 +302,44 @@ try:
|
||||
raise AssertionError("audit actions")
|
||||
record("runtime.audit_visible", "passed", f"аудит содержит {sorted(required)}")
|
||||
|
||||
# 5b. Нагрузочный smoke чтения: курс, лабы, профиль и аудит.
|
||||
load_paths = [
|
||||
(gateway + "/api/v1/courses/" + course["id"], teacher_token),
|
||||
(gateway + "/api/v1/labs?courseId=" + course["id"], teacher_token),
|
||||
(gateway + "/api/v1/courses/" + course["id"] + "/students/" + student["id"] + "/profile", teacher_token),
|
||||
(gateway + "/api/v1/audit?limit=100", admin_token),
|
||||
]
|
||||
|
||||
def timed_get(path, token):
|
||||
started = time.perf_counter()
|
||||
status, _ = req("GET", path, token=token)
|
||||
return status, time.perf_counter() - started
|
||||
|
||||
latencies = []
|
||||
errors = 0
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
|
||||
futures = [
|
||||
pool.submit(timed_get, path, token)
|
||||
for _ in range(25)
|
||||
for path, token in load_paths
|
||||
]
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
status, latency = future.result()
|
||||
latencies.append(latency)
|
||||
if status != 200:
|
||||
errors += 1
|
||||
latencies.sort()
|
||||
p95 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.95))]
|
||||
average = statistics.mean(latencies)
|
||||
load_ok = errors == 0 and p95 < 2.0
|
||||
record(
|
||||
"runtime.load_gradebook_profile",
|
||||
"passed" if load_ok else "failed",
|
||||
f"requests={len(latencies)}, errors={errors}, avg={average:.3f}s, p95={p95:.3f}s",
|
||||
)
|
||||
if not load_ok:
|
||||
raise AssertionError("load smoke")
|
||||
|
||||
# 6. Web-страница входа и публичное дерево.
|
||||
status, _ = req("GET", web + "/login")
|
||||
if status != 200:
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
import org.springframework.test.context.DynamicPropertySource
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import ru.ysu.lms.identity.domain.GroupStatus
|
||||
import ru.ysu.lms.identity.domain.PromotionAction
|
||||
import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import ru.ysu.lms.identity.roster.CreateGroupRequest
|
||||
import ru.ysu.lms.identity.roster.CreateStudentRequest
|
||||
import ru.ysu.lms.identity.roster.GroupService
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Model-based проверка транзакционного apply/rollback на сгенерированных
|
||||
* когортах: после apply состояние соответствует плану, после rollback —
|
||||
* ровно before-state.
|
||||
*/
|
||||
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
|
||||
@Testcontainers
|
||||
@Transactional
|
||||
class PromotionApplyRollbackPropertyTest {
|
||||
companion object {
|
||||
@Container
|
||||
val pg =
|
||||
PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun datasource(registry: DynamicPropertyRegistry) {
|
||||
registry.add("spring.datasource.url") { pg.jdbcUrl }
|
||||
registry.add("spring.datasource.username") { "lms" }
|
||||
registry.add("spring.datasource.password") { "lms" }
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired lateinit var promotionService: PromotionService
|
||||
|
||||
@Autowired lateinit var groupService: GroupService
|
||||
|
||||
@Autowired lateinit var groups: GroupRepository
|
||||
|
||||
@Autowired lateinit var students: StudentRepository
|
||||
|
||||
@Test
|
||||
fun `generated cohorts apply to plan and rollback to before-state`() {
|
||||
val random = Random(20261029)
|
||||
repeat(10) { index ->
|
||||
val degree = if (random.nextBoolean()) "БО" else "МО"
|
||||
val limit = if (degree == "БО") 4 else 2
|
||||
val course = random.nextInt(1, limit + 1)
|
||||
val year = 2040 + index
|
||||
val group =
|
||||
groupService.create(
|
||||
CreateGroupRequest(
|
||||
stream = "PR",
|
||||
course = course.toShort(),
|
||||
number = (index + 1).toShort(),
|
||||
degree = degree,
|
||||
enrollmentYear = (year - 1).toShort(),
|
||||
),
|
||||
)
|
||||
val beforeName = group.name
|
||||
val student =
|
||||
groupService.createStudent(
|
||||
UUID.fromString(group.id),
|
||||
CreateStudentRequest(
|
||||
studentNo = "PR-$index-${UUID.randomUUID().toString().take(8)}",
|
||||
lastName = "Тестов",
|
||||
),
|
||||
null,
|
||||
)
|
||||
val studentId = UUID.fromString(student.id)
|
||||
val plan = promotionService.dryRun(year, null)
|
||||
val planItem = plan.items.first { it.entityId == group.id }
|
||||
val expectedAction = if (course < limit) PromotionAction.PROMOTE else PromotionAction.GRADUATE
|
||||
|
||||
assertEquals(expectedAction.name, planItem.action)
|
||||
promotionService.apply(UUID.fromString(plan.id), null)
|
||||
val afterApply = groups.findById(UUID.fromString(group.id)).orElseThrow()
|
||||
if (course < limit) {
|
||||
assertEquals((course + 1).toShort(), afterApply.course)
|
||||
assertTrue(afterApply.name != beforeName)
|
||||
} else {
|
||||
assertEquals(GroupStatus.GRADUATED, afterApply.status)
|
||||
}
|
||||
|
||||
promotionService.rollback(UUID.fromString(plan.id), null)
|
||||
val afterRollback = groups.findById(UUID.fromString(group.id)).orElseThrow()
|
||||
assertEquals(beforeName, afterRollback.name)
|
||||
assertEquals(course.toShort(), afterRollback.course)
|
||||
assertEquals(GroupStatus.ACTIVE, afterRollback.status)
|
||||
assertEquals(
|
||||
students.findById(studentId).orElseThrow().status,
|
||||
ru.ysu.lms.identity.domain.StudentStatus.ACTIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
import org.springframework.test.context.DynamicPropertySource
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.identity.domain.GroupStatus
|
||||
import ru.ysu.lms.identity.domain.MembershipAction
|
||||
import ru.ysu.lms.identity.domain.PromotionMode
|
||||
import ru.ysu.lms.identity.domain.PromotionRunStatus
|
||||
import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
|
||||
import ru.ysu.lms.identity.repo.PromotionRunRepository
|
||||
import ru.ysu.lms.identity.roster.CreateGroupRequest
|
||||
import ru.ysu.lms.identity.roster.CreateStudentRequest
|
||||
import ru.ysu.lms.identity.roster.GroupService
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Конкурентный apply/rollback одного запуска. Класс не @Transactional:
|
||||
* каждый вызов сервиса должен получить собственную транзакцию и advisory lock.
|
||||
*/
|
||||
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
|
||||
@Testcontainers
|
||||
class PromotionConcurrencyIntegrationTest {
|
||||
companion object {
|
||||
@Container
|
||||
val pg =
|
||||
PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun datasource(registry: DynamicPropertyRegistry) {
|
||||
registry.add("spring.datasource.url") { pg.jdbcUrl }
|
||||
registry.add("spring.datasource.username") { "lms" }
|
||||
registry.add("spring.datasource.password") { "lms" }
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired lateinit var promotionService: PromotionService
|
||||
|
||||
@Autowired lateinit var groupService: GroupService
|
||||
|
||||
@Autowired lateinit var groups: GroupRepository
|
||||
|
||||
@Autowired lateinit var history: MembershipHistoryRepository
|
||||
|
||||
@Autowired lateinit var runs: PromotionRunRepository
|
||||
|
||||
@Test
|
||||
fun `concurrent apply of one run is idempotent and writes history once`() {
|
||||
val fixture = fixture(number = 1, studentNo = "CONC-APPLY", year = 2030)
|
||||
val plan = promotionService.dryRun(2030, null)
|
||||
val runId = UUID.fromString(plan.id)
|
||||
|
||||
val results = runConcurrently { promotionService.apply(runId, null) }
|
||||
|
||||
assertTrue(results.all { it.isSuccess }, "оба apply должны завершиться идемпотентно")
|
||||
assertEquals(1, runs.findAll().count { it.year == 2030 && it.mode == PromotionMode.APPLY })
|
||||
assertEquals(1, history.findByStudentIdOrderByAtDesc(fixture.studentId).count { it.action == MembershipAction.PROMOTE })
|
||||
assertEquals(2.toShort(), groups.findById(fixture.groupId).orElseThrow().course)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent rollback allows exactly one rollback and restores once`() {
|
||||
val fixture = fixture(number = 2, studentNo = "CONC-ROLLBACK", year = 2031)
|
||||
val plan = promotionService.dryRun(2031, null)
|
||||
val runId = UUID.fromString(plan.id)
|
||||
promotionService.apply(runId, null)
|
||||
|
||||
val results = runConcurrently { promotionService.rollback(runId, null) }
|
||||
|
||||
assertEquals(1, results.count { it.isSuccess })
|
||||
assertEquals(1, results.count { it.exceptionOrNull() is ConflictException })
|
||||
assertEquals(PromotionRunStatus.ROLLED_BACK, runs.findById(runId).orElseThrow().status)
|
||||
assertEquals(1.toShort(), groups.findById(fixture.groupId).orElseThrow().course)
|
||||
assertEquals(GroupStatus.ACTIVE, groups.findById(fixture.groupId).orElseThrow().status)
|
||||
}
|
||||
|
||||
private fun runConcurrently(block: () -> Unit): List<Result<Unit>> {
|
||||
val start = CountDownLatch(1)
|
||||
val pool = Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
val futures =
|
||||
(1..2).map {
|
||||
pool.submit<Result<Unit>> {
|
||||
start.await()
|
||||
runCatching { block() }
|
||||
}
|
||||
}
|
||||
start.countDown()
|
||||
return futures.map { it.get(30, TimeUnit.SECONDS) }
|
||||
} finally {
|
||||
pool.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixture(
|
||||
number: Short,
|
||||
studentNo: String,
|
||||
year: Int,
|
||||
): Fixture {
|
||||
val group =
|
||||
groupService.create(
|
||||
CreateGroupRequest(
|
||||
stream = "ПИ",
|
||||
course = 1,
|
||||
number = number,
|
||||
degree = "БО",
|
||||
enrollmentYear = (year - 1).toShort(),
|
||||
),
|
||||
)
|
||||
val student =
|
||||
groupService.createStudent(
|
||||
UUID.fromString(group.id),
|
||||
CreateStudentRequest(studentNo = studentNo, lastName = "Тестов"),
|
||||
null,
|
||||
)
|
||||
return Fixture(UUID.fromString(group.id), UUID.fromString(student.id))
|
||||
}
|
||||
|
||||
private data class Fixture(
|
||||
val groupId: UUID,
|
||||
val studentId: UUID,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user