Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.checkup.checkup.domain.face.entity.FaceTemplate;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -22,11 +21,30 @@ public interface FaceTemplateRepository extends JpaRepository<FaceTemplate, Long
@Query("SELECT template FROM FaceTemplate template WHERE template.student.id = :studentId")
Optional<FaceTemplate> findForUpdateByStudentId(@Param("studentId") Long studentId);

@EntityGraph(attributePaths = "student")
List<FaceTemplate> findAllByOrderByStudent_IdAsc();

@EntityGraph(attributePaths = "student")
List<FaceTemplate> findAllByStudent_IdIn(Collection<Long> studentIds);

void deleteByStudent_Id(Long studentId);

/**
* 출석 대상 학생의 템플릿만 학생 id 순으로 읽는다. 얼굴 인식 세션을 만들 때 쓴다.
* 조건은 {@link com.checkup.checkup.domain.member.entity.Student#isAttendanceEligible()}(동의 2종, 호실)과
* DataGSM 학생 id가 있는 것이다. 출석 대상 규칙이 바뀌면 이 쿼리와 {@link #findAllEligibleByStudentIdIn}도 같이 바꾼다.
*/
@Query("""
SELECT template FROM FaceTemplate template JOIN FETCH template.student student
WHERE student.privacyAgreedAt IS NOT NULL AND student.faceAgreedAt IS NOT NULL
AND student.dormitoryRoom IS NOT NULL AND student.datagsmStudentId > 0
ORDER BY student.id
""")
List<FaceTemplate> findAllEligible();

/**
* 주어진 학생 중 출석 대상인 학생의 템플릿만 읽는다. AI 세션을 다시 만들 때 쓴다.
* 조건은 {@link #findAllEligible()}와 같다.
*/
@Query("""
SELECT template FROM FaceTemplate template JOIN FETCH template.student student
WHERE student.id IN :studentIds
AND student.privacyAgreedAt IS NOT NULL AND student.faceAgreedAt IS NOT NULL
AND student.dormitoryRoom IS NOT NULL AND student.datagsmStudentId > 0
""")
List<FaceTemplate> findAllEligibleByStudentIdIn(@Param("studentIds") Collection<Long> studentIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,7 @@ private static Long canonicalStudentId(String value) {
}

private List<FaceTemplate> eligibleTemplates() {
return faceTemplateRepository.findAllByOrderByStudent_IdAsc().stream()
.filter(template -> eligibleStudent(template.getStudent()))
.toList();
return faceTemplateRepository.findAllEligible();
}

private AiFaceSessionRequest sessionRequest(List<FaceTemplate> templates) {
Expand All @@ -451,11 +449,6 @@ private AiFaceSessionRequest sessionRequest(List<FaceTemplate> templates) {
return new AiFaceSessionRequest(model, candidates);
}

private static boolean eligibleStudent(Student student) {
return student.isAttendanceEligible()
&& student.getDatagsmStudentId() != null && student.getDatagsmStudentId() > 0;
}

private AiFaceSessionRequest candidateRequest(Set<Long> studentIds) {
return candidateRequestSnapshot(studentIds, null).request();
}
Expand All @@ -475,9 +468,7 @@ private Map<Long, Long> candidateTemplateRevisions(Set<Long> studentIds) {
}

private List<FaceTemplate> candidateTemplates(Set<Long> studentIds) {
List<FaceTemplate> templates = faceTemplateRepository.findAllByStudent_IdIn(studentIds).stream()
.filter(template -> eligibleStudent(template.getStudent()))
.toList();
List<FaceTemplate> templates = faceTemplateRepository.findAllEligibleByStudentIdIn(studentIds);
if (templates.size() != studentIds.size() || templates.size() > properties.maxCandidates() || templates.isEmpty()) {
throw new CustomException(ErrorCode.FACE_SESSION_NOT_FOUND);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.checkup.checkup.domain.face.repository;

import static org.assertj.core.api.Assertions.assertThat;

import com.checkup.checkup.domain.face.ai.AiFaceModel;
import com.checkup.checkup.domain.face.entity.FaceTemplate;
import com.checkup.checkup.domain.member.entity.Student;
import com.checkup.checkup.domain.member.repository.StudentRepository;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.test.util.ReflectionTestUtils;

/**
* 실제 PostgreSQL에서 얼굴 인식 세션 후보 쿼리가 출석 대상 학생의 템플릿만 읽는지 검증한다(#236).
* 조건은 {@link Student#isAttendanceEligible()}와 DataGSM 학생 id가 있는 것이다.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class FaceTemplateRepositoryTest {

private static final Instant T0 = Instant.parse("2026-10-13T03:00:00Z");
private static final AiFaceModel MODEL = new AiFaceModel("model-a", "v1", 256, "l2");

@Autowired
private FaceTemplateRepository repository;

@Autowired
private StudentRepository studentRepository;

private Long eligibleId;
private Long otherEligibleId;
private Long noConsentId;
private Long noFaceConsentId;
private Long noPrivacyConsentId;
private Long noRoomId;
private Long noDatagsmIdId;

@BeforeEach
void setUp() {
eligibleId = saveWithTemplate(eligible(94_001L, 301));
otherEligibleId = saveWithTemplate(eligible(94_002L, 302));

noConsentId = saveWithTemplate(Student.createWithoutMember(94_003L, "동의 안 함", 2, 1, 3, 2103, 303));

Student noFaceConsent = eligible(94_004L, 304);
ReflectionTestUtils.setField(noFaceConsent, "faceAgreedAt", null);
noFaceConsentId = saveWithTemplate(noFaceConsent);

Student noPrivacyConsent = eligible(94_005L, 305);
ReflectionTestUtils.setField(noPrivacyConsent, "privacyAgreedAt", null);
noPrivacyConsentId = saveWithTemplate(noPrivacyConsent);

Student noRoom = eligible(94_006L, 306);
noRoom.leaveDormitory();
noRoomId = saveWithTemplate(noRoom);

noDatagsmIdId = saveWithTemplate(eligible(null, 307));
}

@Test
@DisplayName("동의 2종·호실·DataGSM 학생 id가 모두 있는 학생의 템플릿만 학생 id 순으로 읽는다")
void findAllEligibleReturnsOnlyAttendanceEligibleStudents() {
List<FaceTemplate> templates = repository.findAllEligible();

assertThat(templates).extracting(template -> template.getStudent().getId())
.containsExactly(eligibleId, otherEligibleId);
}

@Test
@DisplayName("주어진 학생 중에서도 출석 대상이 아닌 학생의 템플릿은 빠진다")
void findAllEligibleByStudentIdInSkipsIneligibleStudents() {
List<Long> requested = List.of(eligibleId, noConsentId, noFaceConsentId, noPrivacyConsentId,
noRoomId, noDatagsmIdId);

List<FaceTemplate> templates = repository.findAllEligibleByStudentIdIn(requested);

assertThat(templates).extracting(template -> template.getStudent().getId())
.containsExactly(eligibleId);
}

@Test
@DisplayName("쿼리 결과는 Student.isAttendanceEligible()과 같은 판단이다")
void queryMatchesEntityRule() {
List<FaceTemplate> templates = repository.findAllEligible();

assertThat(templates).allSatisfy(template -> {
assertThat(template.getStudent().isAttendanceEligible()).isTrue();
assertThat(template.getStudent().getDatagsmStudentId()).isPositive();
});
assertThat(repository.findAll()).filteredOn(template -> template.getStudent().isAttendanceEligible()
&& template.getStudent().getDatagsmStudentId() != null
&& template.getStudent().getDatagsmStudentId() > 0)
.hasSameSizeAs(templates);
}

private Student eligible(Long datagsmStudentId, int room) {
Student student = Student.createWithoutMember(datagsmStudentId, "학생" + room, 2, 1, room % 100, 2100 + room % 100,
room);
student.agree(false, T0, "v1");
return student;
}

private Long saveWithTemplate(Student student) {
Student saved = studentRepository.save(student);
repository.save(FaceTemplate.create(saved, MODEL, "[[1.0]]"));
return saved.getId();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ void refreshesActiveSessionWithCurrentCandidates() {
FaceTemplate otherTemplate = template(OTHER_STUDENT_DB_ID, OTHER_DATAGSM_STUDENT_ID, vectorJson(1));
given(sessionStore.findWithStudent(STUDENT_DB_ID)).willReturn(List.of(session));
given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true);
given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID)))
given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID)))
.willReturn(List.of(refreshedTemplate, otherTemplate));
service.refreshStudentTemplate(STUDENT_DB_ID);

Expand All @@ -109,7 +109,7 @@ void retriesWhenAnotherCandidateChangesDuringRefresh() {
FaceTemplate otherAfter = versionedTemplate(OTHER_STUDENT_DB_ID, OTHER_DATAGSM_STUDENT_ID, 1L, vectorJson(2));
given(sessionStore.findWithStudent(STUDENT_DB_ID)).willReturn(List.of(session));
given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true);
given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID)))
given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID, OTHER_STUDENT_DB_ID)))
.willReturn(List.of(target, otherBefore), List.of(target, otherAfter));

service.refreshStudentTemplate(STUDENT_DB_ID);
Expand All @@ -128,7 +128,7 @@ void closesSessionWhenTemplateRefreshFails() {
FaceTemplate refreshedTemplate = template(STUDENT_DB_ID, DATAGSM_STUDENT_ID, vectorJson(0));
given(sessionStore.findWithStudent(STUDENT_DB_ID)).willReturn(List.of(session));
given(sessionStore.isOwnedActive(SESSION_ID, ADMIN_ID)).willReturn(true);
given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID)))
given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID)))
.willReturn(List.of(refreshedTemplate));
willThrow(new IllegalStateException("AI unavailable"))
.given(aiFaceClient).createSession(eq(SESSION_ID), any());
Expand Down Expand Up @@ -256,7 +256,7 @@ void recoveryExtendsFrameLock() throws Exception {
given(template.getDimension()).willReturn(256);
given(template.getNormalization()).willReturn("l2");
given(template.getVectorsJson()).willReturn(toVectorsJson());
given(templateRepository.findAllByStudent_IdIn(Set.of(STUDENT_DB_ID))).willReturn(List.of(template));
given(templateRepository.findAllEligibleByStudentIdIn(Set.of(STUDENT_DB_ID))).willReturn(List.of(template));
given(aiFaceClient.recognize(eq(SESSION_ID), eq("frame-1"), any(), any()))
.willAnswer(invocation -> {
clock.advance(Duration.ofSeconds(80));
Expand Down Expand Up @@ -291,7 +291,7 @@ void ownershipIsSavedBeforeAiSessionForCleanupRetry() throws Exception {
given(template.getDimension()).willReturn(256);
given(template.getNormalization()).willReturn("l2");
given(template.getVectorsJson()).willReturn(toVectorsJson());
given(templateRepository.findAllByOrderByStudent_IdAsc()).willReturn(List.of(template));
given(templateRepository.findAllEligible()).willReturn(List.of(template));
FaceSessionView pending = session(Set.of(STUDENT_DB_ID));
given(sessionStore.findOwnedIfPresent(any(), eq(ADMIN_ID))).willReturn(java.util.Optional.of(pending));
org.mockito.BDDMockito.willThrow(new AiFaceException(503, "session_create", "unavailable"))
Expand Down Expand Up @@ -325,7 +325,7 @@ void tooManyCandidatesAreRejectedByConfiguredMaximum() {
tools.jackson.databind.json.JsonMapper.builder().build(), clock);
FaceTemplate first = eligibleTemplate(1L);
FaceTemplate second = eligibleTemplate(2L);
given(templateRepository.findAllByOrderByStudent_IdAsc()).willReturn(List.of(first, second));
given(templateRepository.findAllEligible()).willReturn(List.of(first, second));

org.assertj.core.api.Assertions.assertThatThrownBy(() -> limitedService.create(ADMIN_ID,
com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY))
Expand All @@ -337,6 +337,21 @@ void tooManyCandidatesAreRejectedByConfiguredMaximum() {
verify(aiFaceClient, never()).createSession(any(), any());
}

@Test
@DisplayName("출석 대상 템플릿이 없으면 세션을 만들지 않고 409 FACE_NO_ENROLLED_STUDENTS다")
void noEligibleTemplateIsRejected() {
given(templateRepository.findAllEligible()).willReturn(List.of());

org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.create(ADMIN_ID,
com.checkup.checkup.domain.attendance.entity.AttendancePurpose.DORMITORY))
.isInstanceOfSatisfying(com.checkup.checkup.global.exception.CustomException.class,
error -> assertThat(error.getErrorCode()).isEqualTo(
com.checkup.checkup.global.exception.ErrorCode.FACE_NO_ENROLLED_STUDENTS));

verify(sessionStore, never()).create(any(), any(), any(), any(), any(), any());
verify(aiFaceClient, never()).createSession(any(), any());
}

@Test
@DisplayName("후보 상한을 따로 정하지 않으면 기본 200명이다")
void defaultMaximumIs200() {
Expand Down
Loading