баг-фикс завершён

This commit is contained in:
Zuev
2026-07-19 20:16:12 +03:00
parent bc0e1ab1b4
commit ee876f1acd
43 changed files with 1228 additions and 258 deletions

View File

@@ -1,4 +1,4 @@
FROM maven:3.9-eclipse-temurin-17 AS build
FROM maven:3.9.9-eclipse-temurin-17@sha256:f58d59b6273e785ac0a4477f6e9b5ba1d7731c75b906c0f7b34076f1851318cc AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
@@ -14,7 +14,7 @@ RUN mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy \
-B \
&& echo "${OTEL_JAVAAGENT_SHA256} /app/opentelemetry-javaagent.jar" | sha256sum -c -
FROM eclipse-temurin:17-jre-alpine
FROM eclipse-temurin:17-jre-alpine@sha256:02320dd4ce20e243dfb915c686089cf9315c763084fafbb12d5c9993aee18b57
# Best practice: run as a non-root user
RUN addgroup -S spring && adduser -S spring -G spring

View File

@@ -38,6 +38,10 @@ final class DatabaseConstraintViolationMapper {
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"),
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"),
badRequest("chk_student_groups_group_size_positive", "Численность группы должна быть больше нуля"),
badRequest("chk_student_groups_year_start_positive", "Год начала обучения должен быть больше нуля"),
badRequest("chk_subgroups_student_capacity_positive", "Численность подгруппы должна быть больше нуля"),
badRequest("chk_student_group_subgroup_capacity", "Сумма численностей активных подгрупп не может превышать численность группы"),
badRequest("chk_schedule_rules_type_hours_non_negative", "Количество академических часов не может быть отрицательным"),
badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"),
badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"),

View File

@@ -6,7 +6,6 @@ import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
@@ -31,7 +30,6 @@ import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@@ -43,9 +41,6 @@ public class GroupController {
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
private final GroupRepository groupRepository;
private final EducationFormRepository educationFormRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository specialtyProfileRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
@@ -54,25 +49,18 @@ public class GroupController {
private final BusinessTimeService businessTime;
public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository specialtyProfileRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService,
AcademicStructureService academicStructureService) {
this(groupRepository, educationFormRepository, specialtiesRepository,
specialtyProfileRepository, calendarSubjectRepository, assignmentRepository,
this(groupRepository, calendarSubjectRepository, assignmentRepository,
scheduleGeneratorService, groupLifecycleService, academicStructureService,
BusinessTimeService.systemDefault());
}
@Autowired
public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository specialtyProfileRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
@@ -80,9 +68,6 @@ public class GroupController {
AcademicStructureService academicStructureService,
BusinessTimeService businessTime) {
this.groupRepository = groupRepository;
this.educationFormRepository = educationFormRepository;
this.specialtiesRepository = specialtiesRepository;
this.specialtyProfileRepository = specialtyProfileRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
@@ -144,46 +129,10 @@ public class GroupController {
@PostMapping
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
try {
ResponseEntity<?> validationError = validateGroupRequest(request);
if (validationError != null) {
return validationError;
}
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
if (efOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
}
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
.orElse(null);
if (speciality == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
}
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
.orElse(null);
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
}
StudentGroup group = new StudentGroup();
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(efOpt.get());
group.setDepartmentId(request.getDepartmentId());
group.setYearStartStudy(request.getYearStartStudy());
group.setSpeciality(speciality);
group.setSpecialtyProfile(profile);
groupRepository.save(group);
logger.info("Группа успешно создана с ID - {}", group.getId());
return ResponseEntity.ok(mapToResponse(group));
} catch (Exception e) {
logger.error("Ошибка при создании группы", e);
throw e;
}
logger.info("Получен запрос на создание новой группы");
StudentGroup group = academicStructureService.createGroup(request);
logger.info("Группа успешно создана с ID - {}", group.getId());
return ResponseEntity.ok(mapToResponse(group));
}
@PutMapping("/{id}")
@@ -222,36 +171,6 @@ public class GroupController {
return ResponseEntity.ok(mapToResponse(group));
}
private ResponseEntity<?> validateGroupRequest(CreateGroupRequest request) {
if (request.getName() == null || request.getName().isBlank()) {
return validationError("Название группы обязательно");
}
if (request.getGroupSize() == null) {
return validationError("Численность группы обязательна");
}
if (request.getEducationFormId() == null) {
return validationError("Форма обучения обязательна");
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
return validationError("ID кафедры обязателен");
}
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
return validationError("Год начала обучения обязателен");
}
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
return validationError("Код специальности обязателен");
}
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
return validationError("Профиль обучения обязателен");
}
return null;
}
private ResponseEntity<?> validationError(String errorMessage) {
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
private GroupResponse mapToResponse(StudentGroup group) {
LocalDate today = businessTime.today();
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today);

View File

@@ -10,6 +10,7 @@ import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -53,8 +54,9 @@ public class SubgroupController {
@PostMapping("/api/groups/{groupId}/subgroups")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
@Transactional
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
StudentGroup group = groupRepository.findById(groupId).orElse(null);
StudentGroup group = groupRepository.findByIdForUpdate(groupId).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
@@ -81,9 +83,14 @@ public class SubgroupController {
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
@Transactional
public ResponseEntity<?> update(@PathVariable Long groupId,
@PathVariable Long id,
@RequestBody SubgroupDto request) {
StudentGroup group = groupRepository.findByIdForUpdate(groupId).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
if (subgroup == null) {
return ResponseEntity.notFound().build();
@@ -98,7 +105,7 @@ public class SubgroupController {
if (duplicateName) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
}
String capacityError = validateGroupCapacity(subgroup.getStudentGroup(), id, request.studentCapacity());
String capacityError = validateGroupCapacity(group, id, request.studentCapacity());
if (capacityError != null) {
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
}
@@ -112,7 +119,11 @@ public class SubgroupController {
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
@Transactional
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
if (groupRepository.findByIdForUpdate(groupId).isEmpty()) {
return ResponseEntity.notFound().build();
}
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
if (subgroup == null) {
return ResponseEntity.notFound().build();

View File

@@ -17,7 +17,7 @@ public class Subgroup extends LifecycleEntity {
@Column(nullable = false, length = 100)
private String name;
@Column(name = "student_capacity")
@Column(name = "student_capacity", nullable = false)
private Integer studentCapacity;
public Long getId() {

View File

@@ -19,6 +19,7 @@ import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
@@ -41,6 +42,7 @@ public class AcademicStructureService {
private final EducationFormRepository educationFormRepository;
private final GroupRepository groupRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
@@ -52,6 +54,7 @@ public class AcademicStructureService {
EducationFormRepository educationFormRepository,
GroupRepository groupRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
SubgroupRepository subgroupRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
@@ -62,9 +65,22 @@ public class AcademicStructureService {
this.educationFormRepository = educationFormRepository;
this.groupRepository = groupRepository;
this.assignmentRepository = assignmentRepository;
this.subgroupRepository = subgroupRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Transactional
public StudentGroup createGroup(CreateGroupRequest request) {
validateGroupRequest(request);
GroupDimensions candidate = resolveGroupDimensions(request);
StudentGroup group = new StudentGroup();
applyGroupDimensions(group, request, candidate);
StudentGroup saved = saveGroup(group);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
validateCalendarRequest(request);
@@ -122,6 +138,13 @@ public class AcademicStructureService {
validateGroupRequest(request);
StudentGroup group = groupRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
long activeSubgroupCapacity = subgroupRepository.sumActiveStudentCapacityByGroupId(id);
if (request.getGroupSize() < activeSubgroupCapacity) {
throw new IllegalArgumentException(
"Численность группы не может быть меньше суммы численностей активных подгрупп ("
+ activeSubgroupCapacity + " чел.)"
);
}
GroupDimensions candidate = resolveGroupDimensions(request);
boolean incompatibleAssignment = assignmentRepository.findByGroupIdWithDetails(id).stream()
@@ -139,13 +162,7 @@ public class AcademicStructureService {
);
}
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(candidate.studyForm());
group.setDepartmentId(request.getDepartmentId());
group.setYearStartStudy(request.getYearStartStudy());
group.setSpeciality(candidate.speciality());
group.setSpecialtyProfile(candidate.profile());
applyGroupDimensions(group, request, candidate);
StudentGroup saved = saveGroup(group);
clearScheduleCacheAfterCommit();
return saved;
@@ -217,26 +234,38 @@ public class AcademicStructureService {
if (request.getName() == null || request.getName().isBlank()) {
throw new IllegalArgumentException("Название группы обязательно");
}
if (request.getGroupSize() == null) {
throw new IllegalArgumentException("Численность группы обязательна");
if (request.getGroupSize() == null || request.getGroupSize() <= 0) {
throw new IllegalArgumentException("Численность группы должна быть больше нуля");
}
if (request.getEducationFormId() == null) {
if (request.getEducationFormId() == null || request.getEducationFormId() <= 0) {
throw new IllegalArgumentException("Форма обучения обязательна");
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
if (request.getDepartmentId() == null || request.getDepartmentId() <= 0) {
throw new IllegalArgumentException("ID кафедры обязателен");
}
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
throw new IllegalArgumentException("Год начала обучения обязателен");
if (request.getYearStartStudy() == null || request.getYearStartStudy() <= 0) {
throw new IllegalArgumentException("Год начала обучения должен быть больше нуля");
}
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() <= 0) {
throw new IllegalArgumentException("Код специальности обязателен");
}
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() <= 0) {
throw new IllegalArgumentException("Профиль обучения обязателен");
}
}
private void applyGroupDimensions(StudentGroup group,
CreateGroupRequest request,
GroupDimensions candidate) {
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(candidate.studyForm());
group.setDepartmentId(request.getDepartmentId());
group.setYearStartStudy(request.getYearStartStudy());
group.setSpeciality(candidate.speciality());
group.setSpecialtyProfile(candidate.profile());
}
private CalendarDimensions resolveCalendarDimensions(AcademicCalendarDto request) {
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId())
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));

View File

@@ -284,7 +284,8 @@ public class ScheduleGeneratorService {
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
}
if (java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) {
long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1;
if (inclusiveDays > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
}
}

View File

@@ -298,7 +298,8 @@ public class ScheduleQueryService {
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
}
if (ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) {
long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
if (inclusiveDays > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
}
}

View File

@@ -254,12 +254,14 @@ ON CONFLICT (name) DO NOTHING;
CREATE TABLE IF NOT EXISTS student_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
group_size BIGINT NOT NULL,
group_size BIGINT NOT NULL
CONSTRAINT chk_student_groups_group_size_positive CHECK (group_size > 0),
education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
department_id BIGINT NOT NULL REFERENCES departments(id),
specialty_id BIGINT NOT NULL REFERENCES specialties(id),
specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id),
year_start_study BIGINT NOT NULL,
year_start_study BIGINT NOT NULL
CONSTRAINT chk_student_groups_year_start_positive CHECK (year_start_study > 0),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
@@ -289,7 +291,8 @@ CREATE TABLE IF NOT EXISTS subgroups (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
student_capacity INT,
student_capacity INT NOT NULL
CONSTRAINT chk_subgroups_student_capacity_positive CHECK (student_capacity > 0),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
@@ -299,6 +302,76 @@ CREATE TABLE IF NOT EXISTS subgroups (
UNIQUE(group_id, name)
);
CREATE OR REPLACE FUNCTION validate_student_group_subgroup_capacity()
RETURNS TRIGGER AS $$
DECLARE
active_capacity BIGINT;
BEGIN
SELECT COALESCE(SUM(student_capacity), 0)
INTO active_capacity
FROM subgroups
WHERE group_id = NEW.id
AND status <> 'ARCHIVED';
IF active_capacity > NEW.group_size THEN
RAISE EXCEPTION 'Сумма численностей активных подгрупп не может превышать численность группы'
USING ERRCODE = '23514',
CONSTRAINT = 'chk_student_group_subgroup_capacity';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER validate_student_group_subgroup_capacity
BEFORE UPDATE OF group_size ON student_groups
FOR EACH ROW
EXECUTE FUNCTION validate_student_group_subgroup_capacity();
CREATE OR REPLACE FUNCTION validate_subgroup_student_capacity()
RETURNS TRIGGER AS $$
DECLARE
group_capacity BIGINT;
allocated_capacity BIGINT;
BEGIN
SELECT group_size
INTO group_capacity
FROM student_groups
WHERE id = NEW.group_id
FOR UPDATE;
IF group_capacity IS NULL OR NEW.status = 'ARCHIVED' THEN
RETURN NEW;
END IF;
IF TG_OP = 'INSERT' THEN
SELECT COALESCE(SUM(student_capacity), 0)
INTO allocated_capacity
FROM subgroups
WHERE group_id = NEW.group_id
AND status <> 'ARCHIVED';
ELSE
SELECT COALESCE(SUM(student_capacity), 0)
INTO allocated_capacity
FROM subgroups
WHERE group_id = NEW.group_id
AND status <> 'ARCHIVED'
AND id <> OLD.id;
END IF;
IF allocated_capacity + NEW.student_capacity > group_capacity THEN
RAISE EXCEPTION 'Сумма численностей активных подгрупп не может превышать численность группы'
USING ERRCODE = '23514',
CONSTRAINT = 'chk_student_group_subgroup_capacity';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER validate_subgroup_student_capacity
BEFORE INSERT OR UPDATE OF group_id, student_capacity, status ON subgroups
FOR EACH ROW
EXECUTE FUNCTION validate_subgroup_student_capacity();
INSERT INTO subgroups (group_id, name, student_capacity)
SELECT
sg.id,

View File

@@ -46,7 +46,8 @@ class LoginRateLimitConcurrencyIntegrationTest {
private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z");
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@DynamicPropertySource
static void databaseProperties(DynamicPropertyRegistry registry) {

View File

@@ -51,7 +51,8 @@ class RefreshTokenServiceConcurrencyIntegrationTest {
private static final String TENANT = "test-tenant";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@DynamicPropertySource
static void databaseProperties(DynamicPropertyRegistry registry) {

View File

@@ -32,7 +32,8 @@ class AcademicPeriodInvariantMigrationIntegrationTest {
private static final String SEMESTER_OVERLAP_CONSTRAINT = "ex_semesters_year_no_overlap";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V1 разрешает соседние периоды и запрещает overlap или выход семестра за год")

View File

@@ -27,7 +27,8 @@ import static org.assertj.core.api.Assertions.catchThrowable;
class AcademicStructureInvariantIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V1 сохраняет назначение и отклоняет несовместимые изменения графика или группы")
@@ -205,6 +206,123 @@ class AcademicStructureInvariantIntegrationTest {
}
}
@Test
@DisplayName("V1 отклоняет неположительную численность и уменьшение группы ниже активных подгрупп")
void baselineProtectsGroupAndSubgroupCapacityConstraints() throws SQLException {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
long groupId = queryLong(
connection,
"SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"
);
assertSqlState(
() -> insertCapacityGroup(connection, "НУЛЕВАЯ-ГРУППА", 0, 2025),
"23514",
"chk_student_groups_group_size_positive"
);
assertSqlState(
() -> insertCapacityGroup(connection, "НУЛЕВОЙ-ГОД", 10, 0),
"23514",
"chk_student_groups_year_start_positive"
);
assertSqlState(
() -> insertSubgroup(connection, groupId, "Нулевая подгруппа", 0),
"23514",
"chk_subgroups_student_capacity_positive"
);
assertSqlState(
() -> updateLong(
connection,
"UPDATE student_groups SET group_size = ? WHERE id = ?",
24,
groupId
),
"23514",
"Сумма численностей активных подгрупп"
);
assertThat(queryLong(
connection,
"SELECT group_size FROM student_groups WHERE id = ?",
groupId
)).isEqualTo(25);
}
}
@Test
@DisplayName("V1 сериализует конкурентное уменьшение группы и добавление подгруппы")
void baselineProtectsConcurrentGroupAndSubgroupCapacityWrites() throws Exception {
Flyway flyway = baselineFlyway();
flyway.clean();
flyway.migrate();
long groupId;
try (Connection connection = POSTGRES.createConnection("")) {
groupId = insertCapacityGroup(connection, "ГРУППА-ГОНКА-ВМЕСТИМОСТИ", 30, 2025);
insertSubgroup(connection, groupId, "Исходная подгруппа", 20);
}
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
long finalGroupId = groupId;
List<Future<WriteOutcome>> futures = List.of(
executor.submit(() -> concurrentWrite(
"UPDATE student_groups SET group_size = ? WHERE id = ?",
List.of(20L, finalGroupId),
ready,
start
)),
executor.submit(() -> concurrentWrite(
"INSERT INTO subgroups (group_id, name, student_capacity) VALUES (?, 'Новая подгруппа', 10)",
List.of(finalGroupId),
ready,
start
))
);
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<WriteOutcome> outcomes = List.of(
futures.get(0).get(10, TimeUnit.SECONDS),
futures.get(1).get(10, TimeUnit.SECONDS)
);
assertThat(outcomes).filteredOn(WriteOutcome::success).hasSize(1);
assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
.satisfies(outcome -> {
assertThat(outcome.sqlState()).isEqualTo("23514");
assertThat(outcome.message()).contains("Сумма численностей активных подгрупп");
});
try (Connection connection = POSTGRES.createConnection("")) {
long groupSize = queryLong(
connection,
"SELECT group_size FROM student_groups WHERE id = ?",
groupId
);
long subgroupCapacity = queryLong(
connection,
"""
SELECT COALESCE(SUM(student_capacity), 0)
FROM subgroups
WHERE group_id = ? AND status <> 'ARCHIVED'
""",
groupId
);
assertThat(subgroupCapacity).isLessThanOrEqualTo(groupSize);
}
} finally {
start.countDown();
executor.shutdownNow();
}
}
private WriteOutcome concurrentGroupUpdate(long groupId,
long educationFormId,
CountDownLatch ready,
@@ -304,6 +422,63 @@ class AcademicStructureInvariantIntegrationTest {
}
}
private long insertCapacityGroup(Connection connection,
String name,
long groupSize,
int yearStartStudy) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO student_groups (
name,
group_size,
education_form_id,
department_id,
specialty_id,
specialty_profile_id,
year_start_study
)
SELECT
?,
?,
study_form.id,
department.id,
specialty.id,
profile.id,
?
FROM education_forms study_form
CROSS JOIN departments department
CROSS JOIN specialties specialty
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id
WHERE study_form.name = 'Бакалавриат'
AND department.code = 1
AND specialty.specialty_code = '09.03.01'
AND profile.name = 'Без профиля'
RETURNING id
""")) {
statement.setString(1, name);
statement.setLong(2, groupSize);
statement.setInt(3, yearStartStudy);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void insertSubgroup(Connection connection,
long groupId,
String name,
int studentCapacity) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO subgroups (group_id, name, student_capacity)
VALUES (?, ?, ?)
""")) {
statement.setLong(1, groupId);
statement.setString(2, name);
statement.setInt(3, studentCapacity);
statement.executeUpdate();
}
}
private void insertOutOfRangeCalendarDay(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_days (

View File

@@ -23,7 +23,8 @@ import static org.assertj.core.api.Assertions.catchThrowable;
class ScheduleOverrideMigrationIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V1 принимает допустимые варианты и отклоняет недопустимые данные")

View File

@@ -24,7 +24,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
private static final String EXACT_SLOT_CONSTRAINT = "uq_schedule_rule_slots_exact_payload";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V1 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")

View File

@@ -28,7 +28,8 @@ class SubjectOwnershipInvariantIntegrationTest {
private static final String UNIQUE_INDEX = "uq_subjects_name_ci";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V1 запрещает совпадение названия дисциплины без учёта регистра")

View File

@@ -31,7 +31,8 @@ class TeacherDepartmentInvariantIntegrationTest {
private static final String OVERLAP_CONSTRAINT = "ex_teacher_primary_department_no_overlap";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V1 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр")

View File

@@ -30,7 +30,8 @@ class TimeSlotInvariantMigrationIntegrationTest {
private static final String OVERLAP_CONSTRAINT = "ex_time_slots_scope_no_overlap";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V1 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты")

View File

@@ -43,7 +43,8 @@ import static org.mockito.Mockito.verify;
class AcademicCalendarGridServiceIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@DynamicPropertySource
static void databaseProperties(DynamicPropertyRegistry registry) {

View File

@@ -19,6 +19,7 @@ import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
import com.magistr.app.repository.SubgroupRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -69,6 +70,8 @@ class AcademicStructureServiceTest {
@Mock
private StudentGroupCalendarAssignmentRepository assignmentRepository;
@Mock
private SubgroupRepository subgroupRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
private AcademicStructureService service;
@@ -92,6 +95,7 @@ class AcademicStructureServiceTest {
educationFormRepository,
groupRepository,
assignmentRepository,
subgroupRepository,
scheduleGeneratorService
);
academicYear = academicYear();
@@ -111,6 +115,7 @@ class AcademicStructureServiceTest {
when(educationFormRepository.findById(FORM_ID)).thenReturn(Optional.of(studyForm));
when(educationFormRepository.findById(OTHER_FORM_ID)).thenReturn(Optional.of(otherStudyForm));
when(groupRepository.findByIdForUpdate(GROUP_ID)).thenReturn(Optional.of(group));
when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(0L);
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(groupRepository.saveAndFlush(any(StudentGroup.class)))
@@ -192,6 +197,56 @@ class AcademicStructureServiceTest {
verify(scheduleGeneratorService).clearCache();
}
@Test
void createsGroupThroughSharedValidationAndClearsCache() {
StudentGroup saved = service.createGroup(groupRequest(2025, FORM_ID));
assertThat(saved.getName()).isEqualTo("ИВТ-26");
assertThat(saved.getGroupSize()).isEqualTo(25L);
assertThat(saved.getEducationForm()).isSameAs(studyForm);
verify(groupRepository).saveAndFlush(saved);
verify(scheduleGeneratorService).clearCache();
}
@Test
void rejectsNonPositiveGroupSizeOnCreateAndUpdate() {
CreateGroupRequest createRequest = groupRequest(2025, FORM_ID);
createRequest.setGroupSize(0L);
CreateGroupRequest updateRequest = groupRequest(2025, FORM_ID);
updateRequest.setGroupSize(-1L);
assertThatThrownBy(() -> service.createGroup(createRequest))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Численность группы должна быть больше нуля");
assertThatThrownBy(() -> service.updateGroup(GROUP_ID, updateRequest))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Численность группы должна быть больше нуля");
verify(groupRepository, never()).saveAndFlush(any());
}
@Test
void rejectsGroupSizeBelowActiveSubgroups() {
when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(24L);
CreateGroupRequest request = groupRequest(2025, FORM_ID);
request.setGroupSize(23L);
assertThatThrownBy(() -> service.updateGroup(GROUP_ID, request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Численность группы не может быть меньше суммы численностей активных подгрупп (24 чел.)");
verify(groupRepository, never()).saveAndFlush(any());
}
@Test
void rejectsNonPositiveStudyStartYear() {
CreateGroupRequest request = groupRequest(0, FORM_ID);
assertThatThrownBy(() -> service.createGroup(request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Год начала обучения должен быть больше нуля");
}
@Test
void rejectsAssignmentWhenGroupCourseIsOutsideCalendar() {
group.setYearStartStudy(2020);

View File

@@ -41,7 +41,8 @@ class PostgreSqlAdvisoryLockIntegrationTest {
private static final LocalDate TEST_DATE = LocalDate.of(2026, 9, 1);
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@DynamicPropertySource
static void databaseProperties(DynamicPropertyRegistry registry) {

View File

@@ -13,6 +13,7 @@ import java.util.stream.IntStream;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@@ -134,6 +135,29 @@ class ScheduleGenerationQueryCountTest {
assertThat(repositoryCalls).isEqualTo(7);
}
@Test
@DisplayName("Генератор отклоняет 121 включительную дату")
void rejects121InclusiveDates() {
ScheduleGeneratorService generator = new ScheduleGeneratorService(
mock(ScheduleRuleRepository.class),
mock(GroupRepository.class),
mock(UserRepository.class),
mock(AcademicDateService.class),
mock(TimeSlotRepository.class),
mock(TimeSlotScopeRepository.class),
mock(TimeSlotDateAssignmentRepository.class)
);
LocalDate startDate = LocalDate.of(2026, 1, 1);
assertThatThrownBy(() -> generator.buildScheduleForGroups(
List.of(),
startDate,
startDate.plusDays(120)
))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Диапазон расписания не может превышать 120 дней");
}
private AcademicYear academicYear() {
AcademicYear academicYear = new AcademicYear();
academicYear.setId(1L);

View File

@@ -73,6 +73,37 @@ class ScheduleQueryServiceTest {
verify(groupRepository, never()).findAll();
}
@Test
void rangeLimitCountsBothBoundaryDates() {
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
GroupRepository groupRepository = mock(GroupRepository.class);
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
ScheduleQueryService service = new ScheduleQueryService(
generatorService,
groupRepository,
overrideRepository,
groupLifecycleService
);
LocalDate startDate = LocalDate.of(2026, 1, 1);
LocalDate allowedEndDate = startDate.plusDays(119);
LocalDate rejectedEndDate = startDate.plusDays(120);
when(generatorService.buildScheduleForTeacher(10L, startDate, allowedEndDate))
.thenReturn(List.of());
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, allowedEndDate))
.thenReturn(List.of());
assertThat(service.search(null, 10L, null, null,
null, null, null, null,
startDate, allowedEndDate)).isEmpty();
assertThatThrownBy(() -> service.search(null, 10L, null, null,
null, null, null, null,
startDate, rejectedEndDate))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Диапазон расписания не может превышать 120 дней");
}
@ParameterizedTest
@ValueSource(ints = {51, 100})
void aggregationSearchProcessesAllActiveGroupsWithoutInteractiveLimit(int groupCount) {

View File

@@ -53,7 +53,8 @@ import static org.mockito.Mockito.verify;
class ScheduleRuleServiceConcurrencyIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@DynamicPropertySource
static void databaseProperties(DynamicPropertyRegistry registry) {

View File

@@ -42,7 +42,8 @@ class TenantLifecyclePostgreSqlIntegrationTest {
private static final String VALID_DATABASE = "tenant_valid";
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
private TenantRoutingDataSource routingDataSource;
private TenantDatabaseMigrationService migrationService;

View File

@@ -0,0 +1,16 @@
package com.magistr.app.testing;
import org.testcontainers.utility.DockerImageName;
/**
* Единые воспроизводимые образы интеграционных тестов.
*/
public final class TestContainerImages {
public static final DockerImageName POSTGRES = DockerImageName.parse(
"postgres:16.3-alpine@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41"
).asCompatibleSubstituteFor("postgres");
private TestContainerImages() {
}
}