diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java index 03d2b5ac..2c31ca2d 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java @@ -14,6 +14,13 @@ public record AdminPracticeLogResponseDto( Long point, LocalDateTime createdAt ) { + /** + * {@code point} 는 저장된 값이 아니라 미션 타입의 정가다. + * + *

{@code mission_log.point} 는 "이 기록으로 자동 적립이 돌았는가"를 담게 되어, 미션을 받을 수 있는 + * 앱에서 온 요청의 행은 0 이다(#318). 이 화면이 보여 주는 값의 뜻은 "복습 세트 완료 하나의 값어치"라 + * 적립 여부와 무관하므로, 화면이 지금까지 보여 주던 값을 그대로 유지한다. + */ public static AdminPracticeLogResponseDto from(MissionLog missionLog, PracticeNote practiceNote) { return new AdminPracticeLogResponseDto( missionLog.getId(), @@ -22,7 +29,7 @@ public static AdminPracticeLogResponseDto from(MissionLog missionLog, PracticeNo missionLog.getUser().getEmail(), missionLog.getReferenceId(), practiceNote != null ? practiceNote.getTitle() : "-", - missionLog.getPoint(), + missionLog.getMissionType().getPoint(), missionLog.getCreatedAt() ); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java index 4de17852..95ede576 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java @@ -31,15 +31,38 @@ public class MissionLog extends BaseEntity { private MissionType missionType; + /** + * 이 기록으로 자동 적립 경로가 열렸는가. 열렸으면 그 행동의 정가, 아니면 0 이다. + * + *

예전에는 지급 여부와 무관하게 언제나 정가가 들어갔다. 미션을 받을 수 있는 앱에서 온 요청은 + * 자동 적립을 돌리지 않는데도 정가가 들어가는 바람에, 하루 200점 상한({@code getPointSumToday})이 + * 받지도 않은 점수로 채워져 같은 계정의 구버전 기기 적립까지 막았다(#318). + * + *

상한 계산은 이 값의 합만 본다. 그래서 실제로 적립이 돈 행만 상한을 갉는다. + * 적립이 돈 행에는 상한에 걸려 깎이기 전 정가를 그대로 넣는다. 깎인 실지급액을 넣으면 + * 상한 계산이 달라져 구버전만 쓰는 사용자의 동작이 바뀐다. + * + *

관리자 복습 로그 화면이 보여 주는 점수는 이 값이 아니라 미션 타입의 정가다 + * ({@code AdminPracticeLogResponseDto}). 화면의 뜻은 "이 행동의 값어치"라 적립 여부와 무관하다. + */ private Long point; private Long referenceId; + /** 자동 적립이 도는 요청에서 남기는 기록. 정가가 그대로 들어간다. */ public static MissionLog from(MissionRegisterDto missionRegisterDto, User user) { + return from(missionRegisterDto, user, true); + } + + /** + * @param accrued 이 요청에서 자동 적립이 도는가. 돌지 않으면 {@link #point} 를 0 으로 남겨 + * 하루 상한과 중복 방지 판정이 이 행을 "적립된 행"으로 세지 않게 한다. + */ + public static MissionLog from(MissionRegisterDto missionRegisterDto, User user, boolean accrued) { return MissionLog.builder() .user(user) .missionType(missionRegisterDto.missionType()) - .point(missionRegisterDto.missionType().getPoint()) + .point(accrued ? missionRegisterDto.missionType().getPoint() : 0L) .referenceId(missionRegisterDto.referenceId()) .build(); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java index 7b432589..ceec1ddf 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java @@ -6,17 +6,44 @@ import java.util.List; import java.util.Map; +/** + * 중복 방지 판정에 붙는 {@code accruedOnly} 는 "실제로 적립된 행만 셀 것인가"다. + * + *

{@code mission_log} 행은 미션을 받을 수 있는 앱에서 온 요청도 그대로 남긴다. 관리자 통계와 + * 훈장이 이 테이블만 보기 때문이다. 그런데 그 행을 중복 방지에서도 그대로 세면, 같은 계정을 구버전 + * 기기에서도 쓰는 사용자는 구버전 활동이 "이미 적립했다"로 막혀 적립으로도 진행도로도 남지 않는다(#318). + * + *

그래서 자동 적립이 도는 요청은 적립된 행만 세고({@code accruedOnly = true}), + * 진행도만 올리는 요청은 지금처럼 모든 행을 센다({@code accruedOnly = false}). + * 뒤쪽을 함께 풀면 신버전만 쓰는 사용자가 앱을 열 때마다 출석 진행도가 다시 오른다. + */ public interface MissionLogRepositoryCustom { boolean alreadyWriteProblemsTodayMoreThan3(Long userId); + boolean alreadyWriteProblemsTodayMoreThan3(Long userId, boolean accruedOnly); + long countProblemWritesToday(Long userId); + long countProblemWritesToday(Long userId, boolean accruedOnly); + boolean alreadyPracticeProblem(Long problemId); + boolean alreadyPracticeProblem(Long problemId, boolean accruedOnly); + boolean alreadyPracticeNote(Long practiceNoteId); + boolean alreadyPracticeNote(Long practiceNoteId, boolean accruedOnly); + boolean alreadyLogin(Long userId); + boolean alreadyLogin(Long userId, boolean accruedOnly); + + /** + * 오늘 자동 적립으로 잡힌 점수 합. 하루 200점 상한이 이 값을 본다. + * + *

적립이 돌지 않은 요청의 행은 {@code point} 가 0 이라 저절로 빠진다. + * 조건을 따로 걸지 않는 이유는 그 0 이 이미 "이 행은 적립되지 않았다"를 뜻하기 때문이다. + */ Long getPointSumToday(Long userId); Map getDailyActiveUsersCount(int days); diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java index 260f5356..741c74b6 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java @@ -29,17 +29,28 @@ public MissionLogRepositoryImpl(EntityManager entityManager) { @Override public boolean alreadyWriteProblemsTodayMoreThan3(Long userId) { - return countProblemWritesToday(userId) >= 3; + return alreadyWriteProblemsTodayMoreThan3(userId, false); + } + + @Override + public boolean alreadyWriteProblemsTodayMoreThan3(Long userId, boolean accruedOnly) { + return countProblemWritesToday(userId, accruedOnly) >= 3; } @Override public long countProblemWritesToday(Long userId) { + return countProblemWritesToday(userId, false); + } + + @Override + public long countProblemWritesToday(Long userId, boolean accruedOnly) { Long count = queryFactory .select(missionLog.count()) .from(missionLog) .where(missionLog.missionType.eq(MissionType.PROBLEM_WRITE) .and(missionLog.user.id.eq(userId)) .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchOne(); @@ -48,6 +59,11 @@ public long countProblemWritesToday(Long userId) { @Override public boolean alreadyPracticeProblem(Long problemId){ + return alreadyPracticeProblem(problemId, false); + } + + @Override + public boolean alreadyPracticeProblem(Long problemId, boolean accruedOnly){ return queryFactory .selectOne() @@ -55,34 +71,61 @@ public boolean alreadyPracticeProblem(Long problemId){ .where(missionLog.missionType.eq(MissionType.PROBLEM_PRACTICE) .and(missionLog.referenceId.eq(problemId)) .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchFirst() != null; } @Override public boolean alreadyPracticeNote(Long practiceNoteId){ + return alreadyPracticeNote(practiceNoteId, false); + } + + @Override + public boolean alreadyPracticeNote(Long practiceNoteId, boolean accruedOnly){ return queryFactory .selectOne() .from(missionLog) .where(missionLog.missionType.eq(MissionType.NOTE_PRACTICE) .and(missionLog.referenceId.eq(practiceNoteId)) .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchFirst() != null; } @Override public boolean alreadyLogin(Long userId){ + return alreadyLogin(userId, false); + } + + @Override + public boolean alreadyLogin(Long userId, boolean accruedOnly){ return queryFactory .selectOne() .from(missionLog) .where(missionLog.missionType.eq(MissionType.USER_LOGIN) .and(missionLog.user.id.eq(userId)) .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchFirst() != null; } + /** + * "실제로 적립된 행만" 조건. {@code accruedOnly} 가 아니면 조건을 붙이지 않는다. + * + *

{@code null} 을 돌려주면 QueryDSL 이 그 항을 통째로 빼므로, 기존 판정의 쿼리가 그대로 남는다. + * 적립된 행은 {@code point} 에 정가가 들어 있고, 적립이 돌지 않은 요청의 행은 0 이다 + * ({@code MissionLog.point}). + * + *

옛 행은 적립 여부와 무관하게 정가가 들어 있어 전부 "적립된 행"으로 잡힌다. + * 덜 주는 쪽이 아니라 지금과 같게 두는 쪽이라 안전하다. + */ + private BooleanExpression accrued(boolean accruedOnly) { + return accruedOnly ? missionLog.point.gt(0L) : null; + } + @Override public Long getPointSumToday(Long userId){ Long result = queryFactory diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index 39d6a27e..1a6f8dcc 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -50,6 +50,19 @@ * 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정도 설정과 무관하게 돈다. * 자동 적립이 도는 요청에서는 미션 진행도가 오르지 않는다. 같은 행동으로 적립과 미션 보상을 * 둘 다 받지 않게 하기 위해서다. 진행도를 막는 것은 {@link MissionProgressUpdater} 가 한다. + * + *

중복 방지는 요청이 어느 경로인가에 따라 보는 범위가 다르다. 자동 적립이 도는 요청은 + * 실제로 적립된 행만 보고, 진행도를 올리는 요청은 지금처럼 모든 행을 본다 + * ({@code MissionLogRepositoryCustom} 의 {@code accruedOnly}). + * + *

가르는 이유는 같은 계정을 두 기기에서 쓰는 사용자 때문이다(#318). 신버전 요청이 남긴 행을 + * 구버전 요청의 중복 방지가 그대로 세면, 그 활동은 적립으로도(행에 막혀서) 진행도로도 + * (구버전이라 막혀서) 남지 않아 어느 쪽으로도 XP 가 들어오지 않는다. 반대로 양쪽 판정을 함께 풀면 + * 신버전만 쓰는 사용자가 앱을 열 때마다 출석 진행도가 다시 오르므로, 푸는 것은 적립 쪽 하나뿐이다. + * + *

대신 두 기기를 번갈아 쓰면 같은 대상에 대해 한 번은 적립, 한 번은 진행도가 따로 잡힌다. + * 기기마다 따로 한 활동이라 요청 하나가 두 경로로 받는 것은 아니다. 활동을 통째로 잃는 것보다 낫다고 보고 + * 받아들인 값이다. */ @Service @RequiredArgsConstructor @@ -92,7 +105,8 @@ private User lockUser(Long userId) { public void registerLoginMission(Long userId) { User user = lockUser(userId); - boolean alreadyLogin = missionLogRepository.alreadyLogin(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyLogin = missionLogRepository.alreadyLogin(userId, accrues); if(!alreadyLogin) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -101,7 +115,7 @@ public void registerLoginMission(Long userId) { .missionType(MissionType.USER_LOGIN) .build(); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); @@ -114,7 +128,9 @@ public void registerLoginMission(Long userId) { public void registerProblemWriteMission(Long userId) { User user = lockUser(userId); - boolean alreadyWriteMoreThanThreeProblems = missionLogRepository.alreadyWriteProblemsTodayMoreThan3(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyWriteMoreThanThreeProblems = + missionLogRepository.alreadyWriteProblemsTodayMoreThan3(userId, accrues); if(!alreadyWriteMoreThanThreeProblems) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -123,7 +139,7 @@ public void registerProblemWriteMission(Long userId) { .missionType(MissionType.PROBLEM_WRITE) .build(); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); @@ -132,7 +148,8 @@ public void registerProblemWriteMission(Long userId) { public void registerProblemWriteMissionBatch(Long userId, int count) { User user = lockUser(userId); - long todayCount = missionLogRepository.countProblemWritesToday(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + long todayCount = missionLogRepository.countProblemWritesToday(userId, accrues); int toCreate = (int) Math.min(count, Math.max(0, 3 - todayCount)); if (toCreate == 0) return; @@ -142,7 +159,7 @@ public void registerProblemWriteMissionBatch(Long userId, int count) { .build(); for (int i = 0; i < toCreate; i++) { - MissionLog log = MissionLog.from(dto, user); + MissionLog log = MissionLog.from(dto, user, accrues); missionLogRepository.save(log); addPointToUser(user, log); } @@ -150,7 +167,8 @@ public void registerProblemWriteMissionBatch(Long userId, int count) { public void registerProblemPracticeMission(Long userId, Long problemId) { User user = lockUser(userId); - boolean alreadyPracticeProblem = missionLogRepository.alreadyPracticeProblem(problemId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyPracticeProblem = missionLogRepository.alreadyPracticeProblem(problemId, accrues); if(!alreadyPracticeProblem) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -160,7 +178,7 @@ public void registerProblemPracticeMission(Long userId, Long problemId) { .referenceId(problemId) .build(); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); @@ -169,7 +187,8 @@ public void registerProblemPracticeMission(Long userId, Long problemId) { public void registerNotePracticeMission(Long userId, Long practiceNoteId) { User user = lockUser(userId); - boolean alreadyPracticeNote = missionLogRepository.alreadyPracticeNote(practiceNoteId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyPracticeNote = missionLogRepository.alreadyPracticeNote(practiceNoteId, accrues); if(!alreadyPracticeNote) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -179,7 +198,7 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) { .referenceId(practiceNoteId) .build(); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); @@ -197,7 +216,8 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) { * 중복 방지 판정은 그대로 돈다. 진행도 증가는 반대로 적립하는 요청에서만 막힌다. * *

하루 200점 상한은 이 경로에만 있는 규칙이다. 미션 보상은 {@link MissionRewardGranter} 가 - * 따로 지급하고 상한을 타지 않는다. + * 따로 지급하고 상한을 타지 않는다. 상한이 보는 {@code getPointSumToday} 는 적립이 돈 행만 센다. + * 적립하지 않은 요청의 행은 {@code point} 가 0 이라 저절로 빠진다. */ private Long addPointToUser(User user, MissionLog missionLog) { // 비상 스위치가 꺼졌거나 미션을 받을 수 있는 앱이면 보상 경로 하나만 남긴다. diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionCrossVersionAccrualTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionCrossVersionAccrualTest.java new file mode 100644 index 00000000..eeb0893d --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionCrossVersionAccrualTest.java @@ -0,0 +1,313 @@ +package com.aisip.OnO.backend.mission.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.practicenote.dto.PracticeNoteRegisterDto; +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; +import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.service.ProblemSolveService; +import com.aisip.OnO.backend.user.entity.User; +import java.time.LocalDateTime; +import java.util.List; +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.springframework.beans.factory.annotation.Autowired; + +/** + * 같은 계정을 신버전과 구버전 기기에서 번갈아 쓸 때(#318). + * + *

신버전 요청도 {@code mission_log} 행을 남긴다. 관리자 통계 일곱 곳과 훈장 '개근'이 이 테이블만 보기 때문에 + * 행을 안 남길 수는 없다({@code MissionLogRetentionTest}). 그런데 중복 방지 판정이 그 행을 그대로 세면, + * 뒤이어 온 구버전 요청이 "이미 적립했다"로 막힌다. 구버전 요청은 진행도도 올리지 않으므로 + * 그 활동은 적립으로도 진행도로도 남지 않는다. + * + *

그래서 구버전 요청의 중복 방지는 실제로 적립된 행만 센다. 신버전 요청의 판정은 그대로 + * 모든 행을 세야 한다. 그러지 않으면 신버전 단독 사용자가 앱을 열 때마다 출석 진행도가 다시 오른다. + * 두 방향을 한자리에서 잠근다. + */ +@DisplayName("신·구버전 기기를 번갈아 써도") +class MissionCrossVersionAccrualTest extends MissionSystemTestSupport { + + @Autowired + private ProblemService problemService; + + @Autowired + private ProblemSolveService problemSolveService; + + @Autowired + private PracticeNoteService practiceNoteService; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private PracticeNoteRepository practiceNoteRepository; + + private User user; + private Folder folder; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + folder = fixtures.createRootFolder(user.getId()); + } + + @Nested + @DisplayName("신버전으로 먼저 활동한 뒤 구버전으로 같은 활동을 하면") + class LegacyAfterMissionCapable { + + @Test + @DisplayName("출석 XP 를 받는다") + void loginStillAccrues() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + missionLogService.registerLoginMission(user.getId()); + + requestFromApp(null); + missionLogService.registerLoginMission(user.getId()); + + assertThat(attendanceXp()) + .as("신버전이 남긴 출석 기록 때문에 구버전 출석이 통째로 사라지면 안 된다") + .isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + + @Test + @DisplayName("오답노트 등록 XP 를 받는다") + void problemWriteStillAccrues() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + for (int i = 0; i < 3; i++) { + problemService.registerProblem(problemDto(), user.getId()); + } + + requestFromApp(null); + problemService.registerProblem(problemDto(), user.getId()); + + assertThat(noteWriteXp()) + .as("하루 3건 한도를 신버전 활동이 대신 소진하면 안 된다") + .isEqualTo(MissionType.PROBLEM_WRITE.getPoint()); + } + + @Test + @DisplayName("복습 기록 XP 를 받는다") + void problemPracticeStillAccrues() { + Problem problem = saveProblem(); + + requestFromApp(MISSION_CAPABLE_APP_HEADER); + problemSolveService.createProblemSolve(solveDto(problem), user.getId()); + + requestFromApp(null); + problemSolveService.createProblemSolve(solveDto(problem), user.getId()); + + assertThat(problemPracticeXp()) + .as("같은 문제를 신버전이 먼저 건드렸다고 구버전 복습이 사라지면 안 된다") + .isEqualTo(MissionType.PROBLEM_PRACTICE.getPoint()); + } + + @Test + @DisplayName("세트 완료 XP 를 받는다") + void notePracticeStillAccrues() { + PracticeNote note = savePracticeNote(); + + requestFromApp(MISSION_CAPABLE_APP_HEADER); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + requestFromApp(null); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + assertThat(notePracticeXp()) + .as("같은 세트를 신버전이 먼저 끝냈다고 구버전 완료가 사라지면 안 된다") + .isEqualTo(MissionType.NOTE_PRACTICE.getPoint()); + } + + @Test + @DisplayName("신버전 활동은 하루 200점 상한을 갉아먹지 않는다") + void missionCapableActivityDoesNotFillDailyLimit() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + // 정가로 세면 15 * 14 = 210 점이라 하루 상한 200 을 넘긴다. + for (int i = 0; i < 14; i++) { + practiceNoteService.addPracticeNoteCount(user.getId(), savePracticeNote().getId()); + } + + requestFromApp(null); + missionLogService.registerLoginMission(user.getId()); + + assertThat(attendanceXp()) + .as("지급되지 않은 신버전 활동이 상한을 채워 구버전 적립을 막으면 안 된다") + .isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + + @Test + @DisplayName("신버전이 남긴 기록은 그대로 남는다") + void missionCapableLogRowsRemain() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + missionLogService.registerLoginMission(user.getId()); + + requestFromApp(null); + missionLogService.registerLoginMission(user.getId()); + + assertThat(missionLogRepository.findAllByUserId(user.getId())) + .as("관리자 통계와 훈장이 이 행만 본다. 신버전 행을 지우면 지표가 통째로 0 이 된다") + .isNotEmpty(); + } + } + + @Nested + @DisplayName("신버전만 쓰는 사용자는") + class MissionCapableOnly { + + @Test + @DisplayName("앱을 여러 번 열어도 출석 진행도가 한 번만 오른다") + void loginProgressRisesOnce() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerLoginMission(user.getId()); + + assertThat(currentOf(user.getId(), WEEKLY_ATTEND_5)) + .as("같은 날 다시 접속한 것으로 주간 출석이 차면 안 된다") + .isEqualTo(1); + assertThat(attendanceXp()).as("신버전은 적립을 받지 않는다").isZero(); + } + + @Test + @DisplayName("같은 세트를 반복 완료해도 세트 진행도가 한 번만 오른다") + void practiceNoteProgressRisesOnce() { + PracticeNote note = savePracticeNote(); + requestFromApp(MISSION_CAPABLE_APP_HEADER); + + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + assertThat(currentOf(user.getId(), WEEKLY_SET_3)) + .as("같은 세트를 세 번 완료한 것으로 주간 세트 미션이 채워지면 안 된다") + .isEqualTo(1); + assertThat(notePracticeXp()).as("신버전은 적립을 받지 않는다").isZero(); + } + + @Test + @DisplayName("오답노트 기록은 하루 3건까지만 남는다") + void problemWriteLogsStopAtThree() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + + for (int i = 0; i < 5; i++) { + problemService.registerProblem(problemDto(), user.getId()); + } + + assertThat(missionLogRepository.countProblemWritesToday(user.getId())) + .as("자동 적립 한도 판정은 신버전 요청에서 지금과 같아야 한다") + .isEqualTo(3L); + } + } + + @Nested + @DisplayName("구버전만 쓰는 사용자는") + class LegacyOnly { + + @Test + @DisplayName("같은 활동을 반복해도 지금처럼 한 번만 적립된다") + void accruesOnlyOnce() { + requestFromApp(null); + PracticeNote note = savePracticeNote(); + Problem problem = saveProblem(); + + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerLoginMission(user.getId()); + problemSolveService.createProblemSolve(solveDto(problem), user.getId()); + problemSolveService.createProblemSolve(solveDto(problem), user.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + for (int i = 0; i < 5; i++) { + problemService.registerProblem(problemDto(), user.getId()); + } + + assertThat(attendanceXp()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + assertThat(problemPracticeXp()).isEqualTo(MissionType.PROBLEM_PRACTICE.getPoint()); + assertThat(notePracticeXp()).isEqualTo(MissionType.NOTE_PRACTICE.getPoint()); + assertThat(noteWriteXp()) + .as("오답노트는 하루 3건까지만 적립된다") + .isEqualTo(3 * MissionType.PROBLEM_WRITE.getPoint()); + assertThat(missionLogRepository.findAllByUserId(user.getId())) + .as("출석 1 + 문제복습 1 + 세트 1 + 오답노트 3") + .hasSize(6); + } + } + + @Nested + @DisplayName("구버전으로 먼저 적립한 뒤 신버전으로 같은 활동을 하면") + class MissionCapableAfterLegacy { + + @Test + @DisplayName("진행도는 오르지 않는다") + void progressStaysBlocked() { + requestFromApp(null); + PracticeNote note = savePracticeNote(); + missionLogService.registerLoginMission(user.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + requestFromApp(MISSION_CAPABLE_APP_HEADER); + missionLogService.registerLoginMission(user.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + assertThat(currentOf(user.getId(), WEEKLY_ATTEND_5)) + .as("이미 적립으로 받은 출석을 미션으로 한 번 더 받으면 안 된다") + .isZero(); + assertThat(currentOf(user.getId(), WEEKLY_SET_3)) + .as("이미 적립으로 받은 세트 완료를 미션으로 한 번 더 받으면 안 된다") + .isZero(); + } + } + + private long attendanceXp() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getAttendanceLevel(), status.getAttendancePoint()); + } + + private long noteWriteXp() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getNoteWriteLevel(), status.getNoteWritePoint()); + } + + private long problemPracticeXp() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getProblemPracticeLevel(), status.getProblemPracticePoint()); + } + + private long notePracticeXp() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getNotePracticeLevel(), status.getNotePracticePoint()); + } + + private ProblemRegisterDto problemDto() { + return new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()); + } + + private ProblemSolveRegisterDto solveDto(Problem problem) { + return new ProblemSolveRegisterDto( + problem.getId(), LocalDateTime.now(), AnswerStatus.CORRECT, "회고", null, 120, null); + } + + private Problem saveProblem() { + Problem problem = Problem.from(problemDto(), user.getId()); + problem.updateFolder(folder); + return problemRepository.saveAndFlush(problem); + } + + private PracticeNote savePracticeNote() { + return practiceNoteRepository.save(PracticeNote.from( + new PracticeNoteRegisterDto(null, "복습 세트", List.of(), null), user.getId())); + } +}