diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index e7a8a4a..058033b 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -66,6 +66,13 @@ public enum ErrorStatus implements BaseErrorCode { // 일정 관련 _SCHEDULE_NOT_FOUND(404, "SCHEDULE404", "일정을 찾을 수 없습니다."), _SCHEDULE_ACCESS_DENIED(403, "SCHEDULE403", "해당 스터디에 속하는 일정이 아닙니다."), + _SCHEDULE_QR_CODE_ALREADY_ASSIGNED(400, "SCHEDULE4000", "이미 출석 QR 코드가 할당된 일정입니다."), + + // 출석 관련 + _ATTENDANCE_NOT_IN_SCHEDULE_TIME(400, "ATTENDANCE4000", "일정 시간 외에는 출석체크를 진행할 수 없습니다."), + _ATTENDANCE_NOT_STARTED(400, "ATTENDANCE4001", "출석체크가 시작되지 않았습니다."), + _ATTENDANCE_ALREADY_CHECKED(400, "ATTENDANCE4002", "이미 출석체크를 완료했습니다."), + _INVALID_ATTENDANCE_TOKEN(400, "ATTENDANCE4003", "유효하지 않은 출석 토큰입니다."), // 투두 관련 _TODO_NOT_FOUND(404, "TODO404", "투두를 찾을 수 없습니다."), diff --git a/modules/study/build.gradle b/modules/study/build.gradle index 2eaa92b..d613c9f 100644 --- a/modules/study/build.gradle +++ b/modules/study/build.gradle @@ -32,6 +32,10 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // QR Code + implementation "com.google.zxing:core:3.5.2" + implementation "com.google.zxing:javase:3.5.2" } def querydslDir = "$buildDir/generated/querydsl" diff --git a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java new file mode 100644 index 0000000..ef6658b --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java @@ -0,0 +1,126 @@ +package kr.spot.schedule.application.command; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.ports.GetMemberInfoPort; +import kr.spot.ports.dto.MemberInfoResponse; +import kr.spot.schedule.application.event.AttendanceStartedEvent; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.domain.vo.MemberInfo; +import kr.spot.schedule.infrastructure.crypto.AttendanceTokenEncryptor; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class AttendanceCommandService { + + private static final List ACTIVE_MEMBER_STATUSES = + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED); + + private final IdGenerator idGenerator; + private final ScheduleRepository scheduleRepository; + private final AttendanceRepository attendanceRepository; + private final StudyMemberRepository studyMemberRepository; + private final StudyAccessValidator studyAccessValidator; + private final GetMemberInfoPort getMemberInfoPort; + private final ApplicationEventPublisher eventPublisher; + private final AttendanceTokenEncryptor tokenEncryptor; + + public void startAttendance(long studyId, long scheduleId, long memberId) { + studyAccessValidator.validateStudyLeader(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.startAttendance(studyId); + + createAttendancesForAllMembers(studyId, scheduleId); + + String qrContent = generateEncryptedToken(studyId, scheduleId); + eventPublisher.publishEvent(AttendanceStartedEvent.of(studyId, scheduleId, qrContent)); + } + + public void stopAttendance(long studyId, long scheduleId, long memberId) { + studyAccessValidator.validateStudyLeader(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.stopAttendance(studyId); + } + + public void checkAttendance(String encryptedToken, long memberId) { + AttendanceTokenData tokenData = decryptToken(encryptedToken); + + studyAccessValidator.validateStudyMember(tokenData.studyId(), memberId); + + Schedule schedule = scheduleRepository.getById(tokenData.scheduleId()); + schedule.validateAttendanceCheckable(); + + Attendance attendance = attendanceRepository + .findByScheduleIdAndMemberInfoMemberId(tokenData.scheduleId(), memberId) + .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_MEMBER_NOT_FOUND)); + + attendance.markAttendance(AttendanceStatus.PRESENT); + } + + private void createAttendancesForAllMembers(long studyId, long scheduleId) { + List activeMembers = studyMemberRepository + .findAllByStudyIdAndStudyMemberStatusIn(studyId, ACTIVE_MEMBER_STATUSES); + + List memberIds = activeMembers.stream() + .map(StudyMember::getMemberId) + .toList(); + + Map memberInfoMap = getMemberInfoPort.getMemberInfo(memberIds); + + List attendances = activeMembers.stream() + .map(studyMember -> { + MemberInfoResponse info = memberInfoMap.get(studyMember.getMemberId()); + MemberInfo memberInfo = MemberInfo.of( + studyMember.getMemberId(), + info.name(), + info.profileImageUrl() + ); + Attendance attendance = Attendance.createPending(idGenerator.nextId(), scheduleId, + memberInfo); + if (studyMember.getStudyMemberStatus() == StudyMemberStatus.OWNER) { + attendance.markAttendance(AttendanceStatus.PRESENT); + } + return attendance; + }) + .toList(); + + attendanceRepository.saveAll(attendances); + } + + private String generateEncryptedToken(long studyId, long scheduleId) { + String plainText = String.format("%d:%d:%d", studyId, scheduleId, Instant.now().toEpochMilli()); + return tokenEncryptor.encrypt(plainText); + } + + private AttendanceTokenData decryptToken(String encryptedToken) { + String plainText = tokenEncryptor.decrypt(encryptedToken); + String[] parts = plainText.split(":"); + if (parts.length != 3) { + throw new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN); + } + return new AttendanceTokenData(Long.parseLong(parts[0]), Long.parseLong(parts[1])); + } + + private record AttendanceTokenData(long studyId, long scheduleId) { + + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java new file mode 100644 index 0000000..3bc6750 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java @@ -0,0 +1,55 @@ +package kr.spot.schedule.application.event; + +import kr.spot.ports.FileStoragePort; +import kr.spot.ports.dto.UploadResult; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.infrastructure.qr.ByteArrayMultipartFile; +import kr.spot.schedule.infrastructure.qr.QrCodeGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AttendanceQrCodeUploadListener { + + private static final String FILE_DIR = "attendance/qr/"; + private static final String CONTENT_TYPE = "image/png"; + + private final QrCodeGenerator qrCodeGenerator; + private final FileStoragePort fileStoragePort; + private final ScheduleRepository scheduleRepository; + + @Async("imageUploadExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleAttendanceStarted(AttendanceStartedEvent event) { + try { + byte[] qrCodeImage = qrCodeGenerator.generate(event.qrContent()); + String filename = generateFilename(event.studyId(), event.scheduleId()); + MultipartFile multipartFile = new ByteArrayMultipartFile(qrCodeImage, filename, CONTENT_TYPE); + + UploadResult result = fileStoragePort.upload(multipartFile, FILE_DIR); + + Schedule schedule = scheduleRepository.getById(event.scheduleId()); + schedule.updateQrCodeImageUrl(result.url()); + + log.info("Successfully uploaded QR code image for schedule: {}", event.scheduleId()); + } catch (Exception e) { + log.error("Failed to upload QR code image for schedule: {}", event.scheduleId(), e); + } + } + + private String generateFilename(long studyId, long scheduleId) { + return String.format("study_%d_schedule_%d_%d.png", studyId, scheduleId, + System.currentTimeMillis()); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java new file mode 100644 index 0000000..e1a70fb --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java @@ -0,0 +1,12 @@ +package kr.spot.schedule.application.event; + +public record AttendanceStartedEvent( + long studyId, + long scheduleId, + String qrContent +) { + + public static AttendanceStartedEvent of(long studyId, long scheduleId, String qrContent) { + return new AttendanceStartedEvent(studyId, scheduleId, qrContent); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java new file mode 100644 index 0000000..e944aaf --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java @@ -0,0 +1,57 @@ +package kr.spot.schedule.application.query; + +import java.util.List; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse.AttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse.MemberInfoResponse; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetAttendanceService { + + private final ScheduleRepository scheduleRepository; + private final AttendanceRepository attendanceRepository; + private final StudyAccessValidator studyAccessValidator; + + public GetAttendanceInfoResponse getAttendanceQrCode(long studyId, long scheduleId, + long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + return GetAttendanceInfoResponse.of( + schedule.isAttendanceActive(), + schedule.getAttendanceQrCodeImageUrl() + ); + } + + public GetAttendanceListResponse getAttendanceList(long studyId, long scheduleId, + long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + List attendances = attendanceRepository.findAllByScheduleId(scheduleId); + + List attendanceInfos = attendances.stream() + .map(attendance -> AttendanceInfoResponse.from( + MemberInfoResponse.from( + attendance.getMemberInfo().getMemberId(), + attendance.getMemberInfo().getMemberName(), + attendance.getMemberInfo().getMemberProfileImageUrl() + ), + attendance.getAttendanceStatus().name(), + attendance.getAttendedAt() + )) + .toList(); + + return GetAttendanceListResponse.from(attendanceInfos, attendanceInfos.size()); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java new file mode 100644 index 0000000..7952019 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java @@ -0,0 +1,50 @@ +package kr.spot.schedule.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import kr.spot.domain.BaseEntity; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.domain.vo.MemberInfo; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE attendance SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Attendance extends BaseEntity { + + @Id + private Long id; + + private Long scheduleId; + + private MemberInfo memberInfo; + + @Enumerated(EnumType.STRING) + private AttendanceStatus attendanceStatus; + + private LocalDateTime attendedAt; + + public static Attendance of(long id, long scheduleId, MemberInfo memberInfo) { + return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, null); + } + + public static Attendance createPending(long id, long scheduleId, MemberInfo memberInfo) { + return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, null); + } + + public void markAttendance(AttendanceStatus status) { + this.attendanceStatus = status; + this.attendedAt = LocalDateTime.now(); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java index 85a7ccc..fda2d53 100644 --- a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java @@ -36,11 +36,14 @@ public class Schedule extends BaseEntity { private LocalDateTime endAt; + private boolean attendanceActive; + + private String attendanceQrCodeImageUrl; + public static Schedule of(Long id, Long studyId, String title, String locationMemo, LocalDateTime startAt, LocalDateTime endAt ) { - return new Schedule(id, studyId, title, locationMemo, startAt, endAt - ); + return new Schedule(id, studyId, title, locationMemo, startAt, endAt, false, null); } public void delete(long studyId) { @@ -55,9 +58,49 @@ public boolean isOngoing(LocalDateTime now) { return !now.isBefore(startAt) && !now.isAfter(endAt); } + public void startAttendance(long studyId) { + validateIsValidAccess(studyId); + validateIsOngoing(); + validateIsNotAttendanceActive(); + this.attendanceActive = true; + } + + public void stopAttendance(long studyId) { + validateIsValidAccess(studyId); + this.attendanceActive = false; + this.attendanceQrCodeImageUrl = null; + } + + public void updateQrCodeImageUrl(String url) { + this.attendanceQrCodeImageUrl = url; + } + + public void validateAttendanceCheckable() { + validateIsAttendanceActive(); + validateIsOngoing(); + } + private void validateIsValidAccess(long studyId) { if (studyId != this.studyId) { throw new GeneralException(ErrorStatus._SCHEDULE_ACCESS_DENIED); } } + + private void validateIsOngoing() { + if (!isOngoing(LocalDateTime.now())) { + throw new GeneralException(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + } + + private void validateIsAttendanceActive() { + if (!this.attendanceActive) { + throw new GeneralException(ErrorStatus._ATTENDANCE_NOT_STARTED); + } + } + + private void validateIsNotAttendanceActive() { + if (this.attendanceActive) { + throw new GeneralException(ErrorStatus._SCHEDULE_QR_CODE_ALREADY_ASSIGNED); + } + } } diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java b/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java new file mode 100644 index 0000000..b17fd2c --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java @@ -0,0 +1,7 @@ +package kr.spot.schedule.domain.enums; + +public enum AttendanceStatus { + PRESENT, // 출석 + ABSENT, // 결석 + UNDECIDED // 미결 +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java b/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java new file mode 100644 index 0000000..128adf2 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java @@ -0,0 +1,26 @@ +package kr.spot.schedule.domain.vo; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class MemberInfo { + + private Long memberId; + + private String memberName; + + private String memberProfileImageUrl; + + public static MemberInfo of(Long memberId, String memberName, String memberProfileImageUrl) { + return new MemberInfo(memberId, memberName, memberProfileImageUrl); + } +} \ No newline at end of file diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java new file mode 100644 index 0000000..ed387a7 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java @@ -0,0 +1,73 @@ +package kr.spot.schedule.infrastructure.crypto; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class AttendanceTokenEncryptor { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH = 128; + private static final int GCM_IV_LENGTH = 12; + + private final SecretKeySpec secretKey; + + public AttendanceTokenEncryptor( + @Value("${app.attendance.encryption-key}") String encryptionKey) { + byte[] keyBytes = encryptionKey.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length != 16 && keyBytes.length != 24 && keyBytes.length != 32) { + throw new IllegalArgumentException("AES key must be 16, 24, or 32 bytes"); + } + this.secretKey = new SecretKeySpec(keyBytes, "AES"); + } + + public String encrypt(String plainText) { + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + byte[] iv = generateIv(); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec); + + byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + byte[] combined = new byte[iv.length + encryptedBytes.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(encryptedBytes, 0, combined, iv.length, encryptedBytes.length); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(combined); + } catch (Exception e) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } + + public String decrypt(String encryptedText) { + try { + byte[] combined = Base64.getUrlDecoder().decode(encryptedText); + byte[] iv = new byte[GCM_IV_LENGTH]; + byte[] encryptedBytes = new byte[combined.length - GCM_IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH); + System.arraycopy(combined, GCM_IV_LENGTH, encryptedBytes, 0, encryptedBytes.length); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec); + + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + return new String(decryptedBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN); + } + } + + private byte[] generateIv() { + byte[] iv = new byte[GCM_IV_LENGTH]; + new java.security.SecureRandom().nextBytes(iv); + return iv; + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java new file mode 100644 index 0000000..188b44e --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java @@ -0,0 +1,15 @@ +package kr.spot.schedule.infrastructure.jpa; + +import java.util.List; +import java.util.Optional; +import kr.spot.schedule.domain.Attendance; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttendanceRepository extends JpaRepository { + + Optional findByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); + + boolean existsByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); + + List findAllByScheduleId(Long scheduleId); +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java new file mode 100644 index 0000000..49eba32 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java @@ -0,0 +1,62 @@ +package kr.spot.schedule.infrastructure.qr; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import org.springframework.web.multipart.MultipartFile; + +public class ByteArrayMultipartFile implements MultipartFile { + + private final byte[] content; + private final String name; + private final String originalFilename; + private final String contentType; + + public ByteArrayMultipartFile(byte[] content, String filename, String contentType) { + this.content = content; + this.name = filename; + this.originalFilename = filename; + this.contentType = contentType; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content == null || content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() throws IOException { + return content; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + throw new UnsupportedOperationException("transferTo is not supported"); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java new file mode 100644 index 0000000..7806b78 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java @@ -0,0 +1,46 @@ +package kr.spot.schedule.infrastructure.qr; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Map; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.springframework.stereotype.Component; + +@Component +public class QrCodeGenerator { + + private static final int DEFAULT_WIDTH = 300; + private static final int DEFAULT_HEIGHT = 300; + private static final String IMAGE_FORMAT = "PNG"; + + public byte[] generate(String content) { + return generate(content, DEFAULT_WIDTH, DEFAULT_HEIGHT); + } + + public byte[] generate(String content, int width, int height) { + try { + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + Map hints = Map.of( + EncodeHintType.CHARACTER_SET, "UTF-8", + EncodeHintType.MARGIN, 1 + ); + + BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, width, height, + hints); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + MatrixToImageWriter.writeToStream(bitMatrix, IMAGE_FORMAT, outputStream); + + return outputStream.toByteArray(); + } catch (WriterException | IOException e) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java new file mode 100644 index 0000000..7cd8fed --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java @@ -0,0 +1,165 @@ +package kr.spot.schedule.presentation.command; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.command.AttendanceCommandService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "스터디 일정 - 출석체크", description = "스터디 일정 출석체크 관련 API") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") +@RequiredArgsConstructor +public class AttendanceCommandController { + + private final AttendanceCommandService attendanceCommandService; + + @Operation( + summary = "출석체크 시작", + description = """ + 스터디장이 출석체크를 시작합니다. + + **동작:** + - 모든 스터디 멤버(OWNER, APPROVED)에 대해 출석 기록 생성 + - 스터디장(OWNER)은 자동으로 출석(PRESENT) 처리 + - 일반 멤버는 미정(UNDECIDED) 상태로 생성 + - QR 코드 이미지는 비동기로 생성되며, `/qr` 엔드포인트에서 조회 가능 + + **권한:** 스터디장만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석체크 시작 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "일정 시간이 아님 (ATTENDANCE4000) / 이미 출석체크 진행 중 (SCHEDULE4000)", + content = @Content(examples = { + @ExampleObject(name = "일정 시간 외", value = """ + {"isSuccess": false, "code": "ATTENDANCE4000", "message": "일정 시간 외에는 출석체크를 진행할 수 없습니다."} + """), + @ExampleObject(name = "이미 진행 중", value = """ + {"isSuccess": false, "code": "SCHEDULE4000", "message": "이미 출석 QR 코드가 할당된 일정입니다."} + """) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디장이 아님 (STUDY403)", + content = @Content(examples = @ExampleObject(value = """ + {"isSuccess": false, "code": "STUDY403", "message": "스터디장만 접근 가능합니다."} + """)) + ) + }) + @PostMapping + public ResponseEntity> startAttendanceCheck( + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.startAttendance(studyId, scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation( + summary = "출석체크 종료", + description = """ + 스터디장이 출석체크를 종료합니다. + + **동작:** + - 출석체크 비활성화 + - QR 코드 이미지 URL 삭제 + + **권한:** 스터디장만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석체크 종료 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디장이 아님 (STUDY403)" + ) + }) + @DeleteMapping + public ResponseEntity> stopAttendanceCheck( + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.stopAttendance(studyId, scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation( + summary = "출석체크 처리 (QR 스캔)", + description = """ + QR 코드를 스캔하여 출석체크를 처리합니다. + + **동작:** + - 암호화된 토큰을 복호화하여 studyId, scheduleId 추출 + - 해당 멤버의 출석 상태를 PRESENT로 변경 + + **QR 코드 토큰:** + - QR 코드에는 암호화된 토큰 문자열만 담겨 있음 + - 앱에서 QR 스캔 후 해당 토큰을 이 API의 token 파라미터로 전달 + + **권한:** 스터디 멤버만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석 처리 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "유효하지 않은 토큰 (ATTENDANCE4003) / 출석체크 미시작 (ATTENDANCE4001) / 일정 시간 외 (ATTENDANCE4000)", + content = @Content(examples = { + @ExampleObject(name = "유효하지 않은 토큰", value = """ + {"isSuccess": false, "code": "ATTENDANCE4003", "message": "유효하지 않은 출석 토큰입니다."} + """), + @ExampleObject(name = "출석체크 미시작", value = """ + {"isSuccess": false, "code": "ATTENDANCE4001", "message": "출석체크가 시작되지 않았습니다."} + """) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디 멤버가 아님 (STUDY4032)" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "출석 기록 없음 (STUDYMEMBER404)" + ) + }) + @PostMapping("/check") + public ResponseEntity> checkAttendance( + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @Parameter( + description = "QR 코드에서 읽은 암호화된 출석 토큰", + required = true, + example = "dK8x2mP9qL3nR7vT..." + ) @RequestParam String token, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.checkAttendance(token, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java new file mode 100644 index 0000000..cfe6bf6 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java @@ -0,0 +1,167 @@ +package kr.spot.schedule.presentation.query; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.query.GetAttendanceService; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "스터디 일정 - 출석체크", description = "스터디 일정 출석체크 관련 API") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") +@RequiredArgsConstructor +public class AttendanceQueryController { + + private final GetAttendanceService getAttendanceService; + + @Operation( + summary = "출석 목록 조회", + description = """ + 해당 일정의 출석 목록을 조회합니다. + + **출석 상태 (attendanceStatus):** + - `PRESENT`: 출석 + - `ABSENT`: 결석 + - `UNDECIDED`: 미정 (아직 출석 안 함) + + **권한:** 스터디 멤버만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석 목록 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = GetAttendanceListResponse.class), + examples = @ExampleObject(value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendances": [ + { + "member": { + "memberId": 1, + "memberName": "홍길동", + "memberProfileImageUrl": "https://example.com/profile1.jpg" + }, + "attendanceStatus": "PRESENT", + "attendedAt": "2024-01-15T14:30:00" + }, + { + "member": { + "memberId": 2, + "memberName": "김철수", + "memberProfileImageUrl": "https://example.com/profile2.jpg" + }, + "attendanceStatus": "UNDECIDED", + "attendedAt": null + } + ], + "totalCount": 2 + } + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디 멤버가 아님 (STUDY4032)" + ) + }) + @GetMapping + public ResponseEntity> getAttendanceList( + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + GetAttendanceListResponse response = getAttendanceService.getAttendanceList(studyId, + scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @Operation( + summary = "출석체크 QR 코드 조회", + description = """ + 출석체크용 QR 코드 이미지 URL을 조회합니다. + + **응답 필드:** + - `attendanceActive`: 출석체크 진행 중 여부 + - `qrCodeImageUrl`: QR 코드 이미지 URL (출석체크 미시작 시 null) + + **QR 코드 내용:** + - QR 코드에는 암호화된 토큰 문자열만 담겨 있음 + - 멤버가 QR 스캔 후 토큰을 `/check` API에 전달하여 출석 처리 + + **권한:** 스터디 멤버만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "QR 코드 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = GetAttendanceInfoResponse.class), + examples = { + @ExampleObject(name = "출석체크 진행 중", value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendanceActive": true, + "qrCodeImageUrl": "https://s3.amazonaws.com/.../qr-code.png" + } + } + """), + @ExampleObject(name = "출석체크 미시작", value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendanceActive": false, + "qrCodeImageUrl": null + } + } + """) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디 멤버가 아님 (STUDY4032)" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "일정을 찾을 수 없음 (SCHEDULE404)" + ) + }) + @GetMapping("/qr") + public ResponseEntity> getAttendanceQrCode( + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + GetAttendanceInfoResponse response = getAttendanceService.getAttendanceQrCode(studyId, + scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java new file mode 100644 index 0000000..d4cb9c7 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java @@ -0,0 +1,11 @@ +package kr.spot.schedule.presentation.query.dto; + +public record GetAttendanceInfoResponse( + boolean attendanceActive, + String qrCodeImageUrl +) { + + public static GetAttendanceInfoResponse of(boolean attendanceActive, String qrCodeImageUrl) { + return new GetAttendanceInfoResponse(attendanceActive, qrCodeImageUrl); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java new file mode 100644 index 0000000..262e03c --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java @@ -0,0 +1,39 @@ +package kr.spot.schedule.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record GetAttendanceListResponse( + List attendances, + int totalCount +) { + + public static GetAttendanceListResponse from(List attendances, + int totalCount) { + return new GetAttendanceListResponse(attendances, totalCount); + } + + public record AttendanceInfoResponse( + MemberInfoResponse member, + String attendanceStatus, + LocalDateTime attendedAt + ) { + + public static AttendanceInfoResponse from(MemberInfoResponse member, String attendanceStatus, + LocalDateTime attendedAt) { + return new AttendanceInfoResponse(member, attendanceStatus, attendedAt); + } + } + + public record MemberInfoResponse( + Long memberId, + String memberName, + String memberProfileImageUrl + ) { + + public static MemberInfoResponse from(Long memberId, String memberName, + String memberProfileImageUrl) { + return new MemberInfoResponse(memberId, memberName, memberProfileImageUrl); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java index 76649a6..15f826e 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java @@ -17,4 +17,7 @@ default StudyMember getStudyMemberById(Long studyMemberId) { boolean existsByStudyIdAndMemberIdAndStudyMemberStatusIn(Long studyId, Long memberId, List studyMemberStatuses); + List findAllByStudyIdAndStudyMemberStatusIn(Long studyId, + List studyMemberStatuses); + } diff --git a/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java new file mode 100644 index 0000000..5d18f3e --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java @@ -0,0 +1,336 @@ +package kr.spot.schedule.application.command; + +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_NAME; +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_PROFILE_URL; +import static kr.spot.schedule.common.AttendanceFixture.OTHER_MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.attendance; +import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; +import static kr.spot.schedule.common.AttendanceFixture.pastSchedule; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.ports.GetMemberInfoPort; +import kr.spot.ports.dto.MemberInfoResponse; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.infrastructure.crypto.AttendanceTokenEncryptor; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AttendanceCommandServiceTest { + + private static final String ENCRYPTED_TOKEN = "encrypted_token"; + private static final String DECRYPTED_TOKEN = STUDY_ID + ":" + SCHEDULE_ID + ":1234567890"; + + @Mock + IdGenerator idGenerator; + @Mock + ScheduleRepository scheduleRepository; + @Mock + AttendanceRepository attendanceRepository; + @Mock + StudyMemberRepository studyMemberRepository; + @Mock + StudyAccessValidator studyAccessValidator; + @Mock + GetMemberInfoPort getMemberInfoPort; + @Mock + ApplicationEventPublisher eventPublisher; + @Mock + AttendanceTokenEncryptor tokenEncryptor; + + AttendanceCommandService service; + + @BeforeEach + void setUp() { + service = new AttendanceCommandService( + idGenerator, + scheduleRepository, + attendanceRepository, + studyMemberRepository, + studyAccessValidator, + getMemberInfoPort, + eventPublisher, + tokenEncryptor + ); + } + + @Nested + @DisplayName("출석체크 시작") + class StartAttendance { + + @Test + @DisplayName("스터디장이 출석체크를 시작하면 일정이 출석체크 활성 상태가 되고 모든 멤버의 출석 기록이 생성된다") + void should_activateAttendanceAndCreateRecords_when_leaderStartsAttendance() { + // given + Schedule schedule = ongoingSchedule(); + StudyMember owner = StudyMember.create(1L, STUDY_ID, MEMBER_ID); + StudyMember member = createApprovedMember(2L, STUDY_ID, OTHER_MEMBER_ID); + MemberInfoResponse ownerInfo = new MemberInfoResponse(MEMBER_NAME, MEMBER_PROFILE_URL); + MemberInfoResponse memberInfo = new MemberInfoResponse("멤버", MEMBER_PROFILE_URL); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + when(studyMemberRepository.findAllByStudyIdAndStudyMemberStatusIn(any(), anyList())) + .thenReturn(List.of(owner, member)); + when(getMemberInfoPort.getMemberInfo(anyList())) + .thenReturn(Map.of(MEMBER_ID, ownerInfo, OTHER_MEMBER_ID, memberInfo)); + when(idGenerator.nextId()).thenReturn(100L, 101L); + when(tokenEncryptor.encrypt(any())).thenReturn(ENCRYPTED_TOKEN); + + // when + service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(schedule.isAttendanceActive()).isTrue(); + verify(eventPublisher).publishEvent(any(Object.class)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(attendanceRepository).saveAll(captor.capture()); + + List savedAttendances = captor.getValue(); + assertThat(savedAttendances).hasSize(2); + + Attendance ownerAttendance = savedAttendances.stream() + .filter(a -> a.getMemberInfo().getMemberId().equals(MEMBER_ID)) + .findFirst() + .orElseThrow(); + assertThat(ownerAttendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + + Attendance memberAttendance = savedAttendances.stream() + .filter(a -> a.getMemberInfo().getMemberId().equals(OTHER_MEMBER_ID)) + .findFirst() + .orElseThrow(); + assertThat(memberAttendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.UNDECIDED); + } + + @Test + @DisplayName("스터디장이 아니면 출석체크를 시작할 수 없다") + void should_throwException_when_notStudyLeader() { + // given + doThrow(new GeneralException(ErrorStatus._ONLY_LEADER_CAN_ACCESS)) + .when(studyAccessValidator).validateStudyLeader(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy(() -> service.startAttendance(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ONLY_LEADER_CAN_ACCESS); + } + + @Test + @DisplayName("이미 출석체크가 진행 중이면 예외를 던진다") + void should_throwException_when_attendanceAlreadyActive() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_QR_CODE_ALREADY_ASSIGNED); + } + + @Test + @DisplayName("일정 시간이 아니면 출석체크를 시작할 수 없다") + void should_throwException_when_scheduleNotOngoing() { + // given + Schedule schedule = pastSchedule(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + } + + @Nested + @DisplayName("출석체크 종료") + class StopAttendance { + + @Test + @DisplayName("스터디장이 출석체크를 종료하면 비활성 상태가 된다") + void should_deactivateAttendance_when_leaderStopsAttendance() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + service.stopAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(schedule.isAttendanceActive()).isFalse(); + assertThat(schedule.getAttendanceQrCodeImageUrl()).isNull(); + } + + @Test + @DisplayName("스터디장이 아니면 출석체크를 종료할 수 없다") + void should_throwException_when_notStudyLeader() { + // given + doThrow(new GeneralException(ErrorStatus._ONLY_LEADER_CAN_ACCESS)) + .when(studyAccessValidator).validateStudyLeader(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy(() -> service.stopAttendance(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ONLY_LEADER_CAN_ACCESS); + } + } + + @Nested + @DisplayName("출석체크 처리") + class CheckAttendance { + + @Test + @DisplayName("스터디 멤버가 토큰으로 출석체크를 하면 출석 상태가 PRESENT로 변경된다") + void should_markAttendancePresent_when_memberChecksInWithToken() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + Attendance existingAttendance = attendance(); + + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + when(attendanceRepository.findByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) + .thenReturn(Optional.of(existingAttendance)); + + // when + service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID); + + // then + assertThat(existingAttendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + assertThat(existingAttendance.getAttendedAt()).isNotNull(); + } + + @Test + @DisplayName("스터디 멤버가 아니면 출석체크를 할 수 없다") + void should_throwException_when_notStudyMember() { + // given + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); + } + + @Test + @DisplayName("출석체크가 시작되지 않았으면 출석할 수 없다") + void should_throwException_when_attendanceNotStarted() { + // given + Schedule schedule = ongoingSchedule(); + + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_STARTED); + } + + @Test + @DisplayName("유효하지 않은 토큰이면 예외를 던진다") + void should_throwException_when_invalidToken() { + // given + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)) + .thenThrow(new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN)); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._INVALID_ATTENDANCE_TOKEN); + } + + @Test + @DisplayName("출석 기록이 없으면 예외를 던진다") + void should_throwException_when_attendanceRecordNotFound() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + when(attendanceRepository.findByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._STUDY_MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("일정 시간이 아니면 출석체크를 할 수 없다") + void should_throwException_when_scheduleTimeExpired() { + // given + Schedule schedule = pastSchedule(); + ReflectionTestUtils.setField(schedule, "attendanceActive", true); + + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + } + + private StudyMember createApprovedMember(long id, long studyId, long memberId) { + StudyMember member = StudyMember.apply(id, studyId, memberId, "테스트"); + ReflectionTestUtils.setField(member, "studyMemberStatus", StudyMemberStatus.APPROVED); + return member; + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java new file mode 100644 index 0000000..29ab87f --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java @@ -0,0 +1,182 @@ +package kr.spot.schedule.application.query; + +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.OTHER_MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.QR_CODE_URL; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.attendance; +import static kr.spot.schedule.common.AttendanceFixture.memberInfo; +import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse; +import kr.spot.study.application.validator.StudyAccessValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class GetAttendanceServiceTest { + + @Mock + ScheduleRepository scheduleRepository; + @Mock + AttendanceRepository attendanceRepository; + @Mock + StudyAccessValidator studyAccessValidator; + + GetAttendanceService service; + + @BeforeEach + void setUp() { + service = new GetAttendanceService(scheduleRepository, attendanceRepository, + studyAccessValidator); + } + + @Nested + @DisplayName("출석체크 QR 코드 조회") + class GetAttendanceQrCode { + + @Test + @DisplayName("출석체크가 활성화된 경우 QR 코드 URL을 반환한다") + void should_returnQrCodeUrl_when_attendanceActive() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + ReflectionTestUtils.setField(schedule, "attendanceQrCodeImageUrl", QR_CODE_URL); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + GetAttendanceInfoResponse response = service.getAttendanceQrCode( + STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.attendanceActive()).isTrue(); + assertThat(response.qrCodeImageUrl()).isEqualTo(QR_CODE_URL); + } + + @Test + @DisplayName("출석체크가 비활성화된 경우 isActive가 false이다") + void should_returnInactive_when_attendanceNotActive() { + // given + Schedule schedule = ongoingSchedule(); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + GetAttendanceInfoResponse response = service.getAttendanceQrCode( + STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.attendanceActive()).isFalse(); + assertThat(response.qrCodeImageUrl()).isNull(); + } + + @Test + @DisplayName("스터디 멤버가 아니면 QR 코드를 조회할 수 없다") + void should_throwException_when_notStudyMember() { + // given + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy( + () -> service.getAttendanceQrCode(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); + } + + @Test + @DisplayName("존재하지 않는 일정은 조회할 수 없다") + void should_throwException_when_scheduleNotFound() { + // given + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)) + .thenThrow(new GeneralException(ErrorStatus._SCHEDULE_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> service.getAttendanceQrCode(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_NOT_FOUND); + } + } + + @Nested + @DisplayName("출석 목록 조회") + class GetAttendanceList { + + @Test + @DisplayName("일정의 출석 목록을 조회한다") + void should_returnAttendanceList_when_requested() { + // given + Attendance attendance1 = attendance(); + Attendance attendance2 = Attendance.createPending(201L, SCHEDULE_ID, memberInfo(OTHER_MEMBER_ID)); + attendance2.markAttendance(AttendanceStatus.PRESENT); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(attendanceRepository.findAllByScheduleId(SCHEDULE_ID)) + .thenReturn(List.of(attendance1, attendance2)); + + // when + GetAttendanceListResponse response = service.getAttendanceList(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.attendances()).hasSize(2); + } + + @Test + @DisplayName("출석 기록이 없으면 빈 목록을 반환한다") + void should_returnEmptyList_when_noAttendanceRecords() { + // given + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(attendanceRepository.findAllByScheduleId(SCHEDULE_ID)).thenReturn(List.of()); + + // when + GetAttendanceListResponse response = service.getAttendanceList(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.attendances()).isEmpty(); + } + + @Test + @DisplayName("스터디 멤버가 아니면 출석 목록을 조회할 수 없다") + void should_throwException_when_notStudyMember() { + // given + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy( + () -> service.getAttendanceList(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java b/modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java new file mode 100644 index 0000000..030b633 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java @@ -0,0 +1,73 @@ +package kr.spot.schedule.common; + +import java.time.LocalDateTime; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.vo.MemberInfo; + +public class AttendanceFixture { + + public static final long STUDY_ID = 1L; + public static final long SCHEDULE_ID = 100L; + public static final long ATTENDANCE_ID = 200L; + public static final long MEMBER_ID = 42L; + public static final long OTHER_MEMBER_ID = 99L; + + public static final String MEMBER_NAME = "테스터"; + public static final String MEMBER_PROFILE_URL = "https://example.com/profile.jpg"; + public static final String QR_CODE_URL = "https://example.com/qr.png"; + + public static MemberInfo memberInfo() { + return MemberInfo.of(MEMBER_ID, MEMBER_NAME, MEMBER_PROFILE_URL); + } + + public static MemberInfo memberInfo(long memberId) { + return MemberInfo.of(memberId, MEMBER_NAME, MEMBER_PROFILE_URL); + } + + public static Attendance attendance() { + return Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + } + + public static Schedule ongoingSchedule() { + LocalDateTime now = LocalDateTime.now(); + return Schedule.of( + SCHEDULE_ID, + STUDY_ID, + "테스트 일정", + "테스트 장소", + now.minusHours(1), + now.plusHours(1) + ); + } + + public static Schedule ongoingScheduleWithAttendance() { + Schedule schedule = ongoingSchedule(); + schedule.startAttendance(STUDY_ID); + return schedule; + } + + public static Schedule pastSchedule() { + LocalDateTime now = LocalDateTime.now(); + return Schedule.of( + SCHEDULE_ID, + STUDY_ID, + "지난 일정", + "테스트 장소", + now.minusHours(3), + now.minusHours(1) + ); + } + + public static Schedule futureSchedule() { + LocalDateTime now = LocalDateTime.now(); + return Schedule.of( + SCHEDULE_ID, + STUDY_ID, + "미래 일정", + "테스트 장소", + now.plusHours(1), + now.plusHours(3) + ); + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java new file mode 100644 index 0000000..d825835 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java @@ -0,0 +1,82 @@ +package kr.spot.schedule.domain; + +import static kr.spot.schedule.common.AttendanceFixture.ATTENDANCE_ID; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.memberInfo; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.domain.vo.MemberInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class AttendanceTest { + + @Nested + @DisplayName("출석 기록 생성") + class CreateAttendance { + + @Test + @DisplayName("출석 기록 생성 시 초기 상태는 UNDECIDED이다") + void should_createWithUndecidedStatus() { + // given + MemberInfo member = memberInfo(); + + // when + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, member); + + // then + assertThat(attendance.getId()).isEqualTo(ATTENDANCE_ID); + assertThat(attendance.getScheduleId()).isEqualTo(SCHEDULE_ID); + assertThat(attendance.getMemberInfo()).isEqualTo(member); + assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.UNDECIDED); + } + } + + @Nested + @DisplayName("출석 상태 변경") + class MarkAttendance { + + @Test + @DisplayName("출석으로 상태를 변경할 수 있다") + void should_markAsPresent() { + // given + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + + // when + attendance.markAttendance(AttendanceStatus.PRESENT); + + // then + assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + } + + @Test + @DisplayName("결석으로 상태를 변경할 수 있다") + void should_markAsAbsent() { + // given + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + + // when + attendance.markAttendance(AttendanceStatus.ABSENT); + + // then + assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.ABSENT); + } + + @Test + @DisplayName("출석 상태 변경 시 출석 시간이 갱신된다") + void should_updateAttendedAt_when_markAttendance() { + // given + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + LocalDateTime testPoint = LocalDateTime.now(); + + // when + attendance.markAttendance(AttendanceStatus.PRESENT); + + // then + assertThat(attendance.getAttendedAt()).isAfterOrEqualTo(testPoint); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java new file mode 100644 index 0000000..b93220a --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java @@ -0,0 +1,218 @@ +package kr.spot.schedule.domain; + +import static kr.spot.schedule.common.AttendanceFixture.QR_CODE_URL; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.futureSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; +import static kr.spot.schedule.common.AttendanceFixture.pastSchedule; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ScheduleAttendanceTest { + + @Nested + @DisplayName("출석체크 시작") + class StartAttendance { + + @Test + @DisplayName("진행 중인 일정에서 출석체크를 시작할 수 있다") + void should_startAttendance_when_scheduleIsOngoing() { + // given + Schedule schedule = ongoingSchedule(); + + // when + schedule.startAttendance(STUDY_ID); + + // then + assertThat(schedule.isAttendanceActive()).isTrue(); + } + + @Test + @DisplayName("다른 스터디의 일정에서는 출석체크를 시작할 수 없다") + void should_throwException_when_differentStudyId() { + // given + Schedule schedule = ongoingSchedule(); + long wrongStudyId = 999L; + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(wrongStudyId)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_ACCESS_DENIED); + } + + @Test + @DisplayName("일정 시간 외에는 출석체크를 시작할 수 없다") + void should_throwException_when_scheduleNotOngoing() { + // given + Schedule schedule = pastSchedule(); + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(STUDY_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + + @Test + @DisplayName("미래 일정에서는 출석체크를 시작할 수 없다") + void should_throwException_when_scheduleInFuture() { + // given + Schedule schedule = futureSchedule(); + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(STUDY_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + + @Test + @DisplayName("이미 출석체크가 진행 중이면 예외를 던진다") + void should_throwException_when_attendanceAlreadyActive() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(STUDY_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_QR_CODE_ALREADY_ASSIGNED); + } + } + + @Nested + @DisplayName("출석체크 종료") + class StopAttendance { + + @Test + @DisplayName("출석체크를 종료하면 비활성 상태가 된다") + void should_deactivateAttendance_when_stopped() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + // when + schedule.stopAttendance(STUDY_ID); + + // then + assertThat(schedule.isAttendanceActive()).isFalse(); + } + + @Test + @DisplayName("출석체크 종료 시 QR 코드 URL이 초기화된다") + void should_clearQrCodeUrl_when_stopped() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + schedule.updateQrCodeImageUrl(QR_CODE_URL); + + // when + schedule.stopAttendance(STUDY_ID); + + // then + assertThat(schedule.getAttendanceQrCodeImageUrl()).isNull(); + } + + @Test + @DisplayName("다른 스터디의 일정은 출석체크를 종료할 수 없다") + void should_throwException_when_differentStudyId() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + long wrongStudyId = 999L; + + // when & then + assertThatThrownBy(() -> schedule.stopAttendance(wrongStudyId)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_ACCESS_DENIED); + } + } + + @Nested + @DisplayName("출석체크 가능 여부 검증") + class ValidateAttendanceCheckable { + + @Test + @DisplayName("출석체크가 활성화되고 진행 중인 일정이면 검증에 성공한다") + void should_passValidation_when_attendanceActiveAndOngoing() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + // when & then (no exception thrown) + schedule.validateAttendanceCheckable(); + } + + @Test + @DisplayName("출석체크가 비활성화되어 있으면 검증에 실패한다") + void should_failValidation_when_attendanceNotActive() { + // given + Schedule schedule = ongoingSchedule(); + + // when & then + assertThatThrownBy(schedule::validateAttendanceCheckable) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_STARTED); + } + } + + @Nested + @DisplayName("QR 코드 URL 업데이트") + class UpdateQrCodeImageUrl { + + @Test + @DisplayName("QR 코드 URL을 업데이트할 수 있다") + void should_updateQrCodeUrl() { + // given + Schedule schedule = ongoingSchedule(); + + // when + schedule.updateQrCodeImageUrl(QR_CODE_URL); + + // then + assertThat(schedule.getAttendanceQrCodeImageUrl()).isEqualTo(QR_CODE_URL); + } + } + + @Nested + @DisplayName("일정 진행 중 확인") + class IsOngoing { + + @Test + @DisplayName("현재 시간이 일정 시간 내에 있으면 true를 반환한다") + void should_returnTrue_when_currentTimeWithinSchedule() { + // given + Schedule schedule = ongoingSchedule(); + + // when & then + assertThat(schedule.isOngoing(java.time.LocalDateTime.now())).isTrue(); + } + + @Test + @DisplayName("일정 시간이 지났으면 false를 반환한다") + void should_returnFalse_when_scheduleEnded() { + // given + Schedule schedule = pastSchedule(); + + // when & then + assertThat(schedule.isOngoing(java.time.LocalDateTime.now())).isFalse(); + } + + @Test + @DisplayName("일정 시간 전이면 false를 반환한다") + void should_returnFalse_when_scheduleNotStarted() { + // given + Schedule schedule = futureSchedule(); + + // when & then + assertThat(schedule.isOngoing(java.time.LocalDateTime.now())).isFalse(); + } + } +}