баг-фикс завершён
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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", "Неделя начала занятия должна быть положительной"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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("Учебный год не найден"));
|
||||
|
||||
@@ -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 дней");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 дней");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 или выход семестра за год")
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 принимает допустимые варианты и отклоняет недопустимые данные")
|
||||
|
||||
@@ -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 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
|
||||
|
||||
@@ -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 запрещает совпадение названия дисциплины без учёта регистра")
|
||||
|
||||
@@ -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 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр")
|
||||
|
||||
@@ -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 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user