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 {@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()));
+ }
+}