Implement game result judgment and automated reward system.
- `dailyArchive` 과정에 `judgeDay`를 통합하여 경기 결과에 따른 일간 판정, 스트릭 업데이트, 티어 포인트 가산을 자동화했습니다. - 주간 모든 경기를 성공적으로 예측했을 때 '주간 마스터 티켓'을 지급하는 `judgeWeekIfNeeded` 로직을 구현했습니다. - Firestore 트랜잭션을 사용하여 판정 결과 반영의 원자성을 확보하고, 중복 처리를 방지하는 멱등성 가드를 적용했습니다. - `statsService`가 실시간 계산 대신 사용자 문서에 저장된 스트릭과 티어 정보를 활용하도록 고도화하고 관련 유틸리티와 테스트 코드를 추가했습니다.
This commit is contained in:
parent
06c6de4660
commit
392ecfbd0b
0
database-debug.log
Normal file
0
database-debug.log
Normal file
0
firestore-debug.log
Normal file
0
firestore-debug.log
Normal file
46
src/constants/judgment.ts
Normal file
46
src/constants/judgment.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import type { DailyJudgment } from "../types/panit.js";
|
||||||
|
|
||||||
|
export interface Thresholds {
|
||||||
|
success: number;
|
||||||
|
perfect: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그날 종료된(=completed) 경기 수를 기준으로 판정 기준을 반환한다.
|
||||||
|
*
|
||||||
|
* - 2경기 이하: `"skip"` — 스트릭 유지, 판정 유보.
|
||||||
|
* - 3경기 이상: 과반(=floor((n+1)/2)) 이상 적중이면 성공, 전부 적중이면 퍼펙트.
|
||||||
|
*
|
||||||
|
* 표(기획서 2.5.1) 일치 검증:
|
||||||
|
* - n=3 → success=2, perfect=3
|
||||||
|
* - n=4 → success=2, perfect=4
|
||||||
|
* - n=5 → success=3, perfect=5
|
||||||
|
*/
|
||||||
|
export function thresholdsFor(completed: number): Thresholds | "skip" {
|
||||||
|
if (completed <= 2) return "skip";
|
||||||
|
return {
|
||||||
|
success: Math.floor((completed + 1) / 2),
|
||||||
|
perfect: completed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 적중 수와 임계값으로 일간 판정을 결정한다.
|
||||||
|
*/
|
||||||
|
export function judgeByCounts(correct: number, t: Thresholds): DailyJudgment {
|
||||||
|
if (correct >= t.perfect) return "perfect";
|
||||||
|
if (correct >= t.success) return "success";
|
||||||
|
return "fail";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스트릭 유지·증가 시 지급되는 보너스 포인트.
|
||||||
|
* 스트릭이 길수록 가산폭이 커지도록 완만한 계단 함수.
|
||||||
|
*/
|
||||||
|
export function streakBonus(streakAfter: number): number {
|
||||||
|
if (streakAfter >= 30) return 20;
|
||||||
|
if (streakAfter >= 14) return 10;
|
||||||
|
if (streakAfter >= 7) return 5;
|
||||||
|
if (streakAfter >= 3) return 2;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
27
src/constants/tiers.ts
Normal file
27
src/constants/tiers.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { TierName } from "../types/panit.js";
|
||||||
|
|
||||||
|
interface TierStep {
|
||||||
|
name: TierName;
|
||||||
|
minPoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIERS: readonly TierStep[] = [
|
||||||
|
{ name: "bronze", minPoints: 0 },
|
||||||
|
{ name: "silver", minPoints: 100 },
|
||||||
|
{ name: "gold", minPoints: 300 },
|
||||||
|
{ name: "platinum", minPoints: 700 },
|
||||||
|
{ name: "diamond", minPoints: 1500 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 누적 티어 포인트로부터 현재 티어명을 파생한다.
|
||||||
|
*/
|
||||||
|
export function tierOf(points: number): TierName {
|
||||||
|
const p = Math.max(0, points);
|
||||||
|
let current: TierName = TIERS[0].name;
|
||||||
|
for (const step of TIERS) {
|
||||||
|
if (p >= step.minPoints) current = step.name;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
@ -1,11 +1,13 @@
|
|||||||
import { FieldValue } from "firebase-admin/firestore";
|
import { FieldValue } from "firebase-admin/firestore";
|
||||||
import { firestore } from "../firebase";
|
import { firestore } from "../firebase";
|
||||||
import type {
|
import type {
|
||||||
|
DailyJudgment,
|
||||||
KnowledgeLevel,
|
KnowledgeLevel,
|
||||||
Provider,
|
Provider,
|
||||||
TeamCode,
|
TeamCode,
|
||||||
User,
|
User,
|
||||||
} from "../types/panit";
|
} from "../types/panit";
|
||||||
|
import type { DateString } from "../types/dateString";
|
||||||
|
|
||||||
const COLLECTION = "users";
|
const COLLECTION = "users";
|
||||||
|
|
||||||
@ -78,3 +80,128 @@ export async function updateUser(
|
|||||||
export async function deleteUser(uid: string): Promise<void> {
|
export async function deleteUser(uid: string): Promise<void> {
|
||||||
await firestore.recursiveDelete(firestore.collection(COLLECTION).doc(uid));
|
await firestore.recursiveDelete(firestore.collection(COLLECTION).doc(uid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DailyJudgmentResult {
|
||||||
|
judgment: DailyJudgment;
|
||||||
|
correctCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
/** 판정 후 스트릭 값. `skip`이면 기존 값과 동일. */
|
||||||
|
streakAfter: number;
|
||||||
|
/** 해당 판정이 멱등성 가드로 인해 생략됐는지 여부. */
|
||||||
|
skippedByGuard: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일간 판정을 user doc에 원자적으로 반영한다. 동일 날짜가 이미 판정된 경우
|
||||||
|
* 현재 저장된 상태를 반환하여 호출자가 멱등 경로에서 voteHistory 쓰기를
|
||||||
|
* 건너뛸 수 있도록 한다.
|
||||||
|
*
|
||||||
|
* - perfect/success → 스트릭 +1, 하이스트릭 갱신, 티어 포인트 가산
|
||||||
|
* - fail → 스트릭 0 (포인트 감점 없음)
|
||||||
|
* - skip → 스트릭 불변, 포인트 불변
|
||||||
|
* - perfect 시 `tickets.dailyAllKill` +1
|
||||||
|
*
|
||||||
|
* `computePoints`는 판정 후 스트릭(streakAfter)을 인자로 받아 해당 판정으로
|
||||||
|
* 획득할 포인트를 반환한다. `fail|skip`일 땐 호출되지 않는다.
|
||||||
|
*/
|
||||||
|
export async function applyDailyJudgmentTx(
|
||||||
|
uid: string,
|
||||||
|
date: DateString,
|
||||||
|
input: {
|
||||||
|
judgment: DailyJudgment;
|
||||||
|
correctCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
computePoints: (streakAfter: number) => number;
|
||||||
|
}
|
||||||
|
): Promise<DailyJudgmentResult> {
|
||||||
|
const ref = firestore.collection(COLLECTION).doc(uid);
|
||||||
|
return firestore.runTransaction(async (tx) => {
|
||||||
|
const snap = await tx.get(ref);
|
||||||
|
const user = (snap.data() ?? {}) as Partial<User>;
|
||||||
|
|
||||||
|
const lastJudged = user.lastJudgedDate;
|
||||||
|
if (lastJudged && lastJudged >= date) {
|
||||||
|
const streakAfter = user.currentStreak ?? 0;
|
||||||
|
return {
|
||||||
|
judgment: input.judgment,
|
||||||
|
correctCount: input.correctCount,
|
||||||
|
completedCount: input.completedCount,
|
||||||
|
streakAfter,
|
||||||
|
skippedByGuard: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStreak = user.currentStreak ?? 0;
|
||||||
|
const highestStreak = user.highestStreak ?? 0;
|
||||||
|
const tierPoints = user.tierPoints ?? 0;
|
||||||
|
const dailyAllKill = user.tickets?.dailyAllKill ?? 0;
|
||||||
|
const weeklyMaster = user.tickets?.weeklyMaster ?? 0;
|
||||||
|
|
||||||
|
let nextStreak = currentStreak;
|
||||||
|
let nextPoints = tierPoints;
|
||||||
|
let nextDailyAllKill = dailyAllKill;
|
||||||
|
|
||||||
|
if (input.judgment === "perfect" || input.judgment === "success") {
|
||||||
|
nextStreak = currentStreak + 1;
|
||||||
|
nextPoints = tierPoints + input.computePoints(nextStreak);
|
||||||
|
if (input.judgment === "perfect") nextDailyAllKill = dailyAllKill + 1;
|
||||||
|
} else if (input.judgment === "fail") {
|
||||||
|
nextStreak = 0;
|
||||||
|
}
|
||||||
|
// skip: 변화 없음.
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
currentStreak: nextStreak,
|
||||||
|
highestStreak: Math.max(highestStreak, nextStreak),
|
||||||
|
tierPoints: nextPoints,
|
||||||
|
tickets: {
|
||||||
|
dailyAllKill: nextDailyAllKill,
|
||||||
|
weeklyMaster,
|
||||||
|
},
|
||||||
|
lastJudgedDate: date,
|
||||||
|
};
|
||||||
|
tx.set(ref, patch, { merge: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
judgment: input.judgment,
|
||||||
|
correctCount: input.correctCount,
|
||||||
|
completedCount: input.completedCount,
|
||||||
|
streakAfter: nextStreak,
|
||||||
|
skippedByGuard: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주간 마스터 티켓을 지급한다. 같은 화요일 키로 이미 지급됐다면 no-op.
|
||||||
|
*
|
||||||
|
* @returns 실제로 지급됐으면 `true`, 멱등 가드에 막혔으면 `false`.
|
||||||
|
*/
|
||||||
|
export async function applyWeeklyMasterTx(
|
||||||
|
uid: string,
|
||||||
|
tuesday: DateString
|
||||||
|
): Promise<boolean> {
|
||||||
|
const ref = firestore.collection(COLLECTION).doc(uid);
|
||||||
|
return firestore.runTransaction(async (tx) => {
|
||||||
|
const snap = await tx.get(ref);
|
||||||
|
const user = (snap.data() ?? {}) as Partial<User>;
|
||||||
|
|
||||||
|
if (user.lastWeeklyMasterTuesday === tuesday) return false;
|
||||||
|
|
||||||
|
const dailyAllKill = user.tickets?.dailyAllKill ?? 0;
|
||||||
|
const weeklyMaster = user.tickets?.weeklyMaster ?? 0;
|
||||||
|
|
||||||
|
tx.set(
|
||||||
|
ref,
|
||||||
|
{
|
||||||
|
tickets: {
|
||||||
|
dailyAllKill,
|
||||||
|
weeklyMaster: weeklyMaster + 1,
|
||||||
|
},
|
||||||
|
lastWeeklyMasterTuesday: tuesday,
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -3,11 +3,16 @@ import { logger } from "firebase-functions";
|
|||||||
import { rtdb } from "../firebase.js";
|
import { rtdb } from "../firebase.js";
|
||||||
import { setDay } from "../repositories/voteHistoryRepository.js";
|
import { setDay } from "../repositories/voteHistoryRepository.js";
|
||||||
import { invalidateStats } from "../services/statsService.js";
|
import { invalidateStats } from "../services/statsService.js";
|
||||||
|
import { judgeDay, judgeWeekIfNeeded } from "../services/judgmentService.js";
|
||||||
import { getGame } from "../repositories/gameRepository.js";
|
import { getGame } from "../repositories/gameRepository.js";
|
||||||
import { deleteUserVoteGame } from "../repositories/voteRepository.js";
|
import { deleteUserVoteGame } from "../repositories/voteRepository.js";
|
||||||
import { processGameEndWithGame } from "../services/gameResultService.js";
|
import { processGameEndWithGame } from "../services/gameResultService.js";
|
||||||
import type { VoteHistoryDoc } from "../types/panit.js";
|
import type { VoteHistoryDoc } from "../types/panit.js";
|
||||||
import { daysAgoKst, type DateString } from "../types/dateString.js";
|
import {
|
||||||
|
dayOfWeekKst,
|
||||||
|
daysAgoKst,
|
||||||
|
type DateString,
|
||||||
|
} from "../types/dateString.js";
|
||||||
|
|
||||||
interface RawVote {
|
interface RawVote {
|
||||||
team: string;
|
team: string;
|
||||||
@ -79,6 +84,7 @@ export const dailyArchive = onSchedule(
|
|||||||
const byUid = snap.val() as Record<string, Record<string, DayVotes>>;
|
const byUid = snap.val() as Record<string, Record<string, DayVotes>>;
|
||||||
|
|
||||||
let archived = 0;
|
let archived = 0;
|
||||||
|
const judgedUids: string[] = [];
|
||||||
for (const uid of Object.keys(byUid)) {
|
for (const uid of Object.keys(byUid)) {
|
||||||
let dayVotes = byUid[uid]?.[date];
|
let dayVotes = byUid[uid]?.[date];
|
||||||
if (!dayVotes) continue;
|
if (!dayVotes) continue;
|
||||||
@ -104,18 +110,31 @@ export const dailyArchive = onSchedule(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리컨실 결과 모든 경기가 cancelled로 제거된 경우: voteHistory에 빈 문서를 남기지 않고 인덱스만 정리.
|
// 리컨실 결과 모든 경기가 cancelled로 제거된 경우에도 skip 판정은 남겨
|
||||||
if (data.length === 0) {
|
// 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다.
|
||||||
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await setDay(uid, date, { data });
|
await setDay(uid, date, { data });
|
||||||
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
|
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
|
||||||
|
try {
|
||||||
|
await judgeDay(uid, date, { data });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`judgeDay failed uid=${uid} date=${date}`, err);
|
||||||
|
}
|
||||||
await invalidateStats(uid).catch(() => undefined);
|
await invalidateStats(uid).catch(() => undefined);
|
||||||
|
judgedUids.push(uid);
|
||||||
archived += 1;
|
archived += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 일요일(dow=0) 아카이브/판정이 끝난 시점에 주간 마스터 티켓 지급 판단.
|
||||||
|
if (dayOfWeekKst(date) === 0) {
|
||||||
|
for (const uid of judgedUids) {
|
||||||
|
try {
|
||||||
|
await judgeWeekIfNeeded(uid, date);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`judgeWeek failed uid=${uid} date=${date}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
|
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
93
src/services/judgmentService.ts
Normal file
93
src/services/judgmentService.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { logger } from "firebase-functions";
|
||||||
|
import { listByDate } from "../repositories/gameRepository.js";
|
||||||
|
import { getRange, setDay } from "../repositories/voteHistoryRepository.js";
|
||||||
|
import {
|
||||||
|
applyDailyJudgmentTx,
|
||||||
|
applyWeeklyMasterTx,
|
||||||
|
} from "../repositories/userRepository.js";
|
||||||
|
import {
|
||||||
|
judgeByCounts,
|
||||||
|
streakBonus,
|
||||||
|
thresholdsFor,
|
||||||
|
} from "../constants/judgment.js";
|
||||||
|
import type { DailyJudgment, VoteHistoryDoc } from "../types/panit.js";
|
||||||
|
import { addDaysKst, tuesdayOf, type DateString } from "../types/dateString.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지정 날짜의 투표 이력을 판정하고 결과를 voteHistory와 user doc에 반영한다.
|
||||||
|
*
|
||||||
|
* - `dailyArchive`가 해당 날짜의 voteHistory 도큐먼트를 기록한 이후에 호출한다.
|
||||||
|
* - 전부 취소되어 투표 데이터가 비어있는 날에도 skip 판정을 남기기 위해 호출 가능.
|
||||||
|
*
|
||||||
|
* @param uid - 유저 ID
|
||||||
|
* @param date - 판정 대상 날짜 (KST)
|
||||||
|
* @param voteDoc - 해당 날짜의 voteHistory 도큐먼트 내용(판정 필드 미포함)
|
||||||
|
*/
|
||||||
|
export async function judgeDay(
|
||||||
|
uid: string,
|
||||||
|
date: DateString,
|
||||||
|
voteDoc: VoteHistoryDoc
|
||||||
|
): Promise<void> {
|
||||||
|
const games = await listByDate(date);
|
||||||
|
const completedCount = games.filter((g) => g.status === "completed").length;
|
||||||
|
const threshold = thresholdsFor(completedCount);
|
||||||
|
|
||||||
|
let judgment: DailyJudgment;
|
||||||
|
let correctCount: number;
|
||||||
|
if (threshold === "skip") {
|
||||||
|
judgment = "skip";
|
||||||
|
correctCount = 0;
|
||||||
|
} else {
|
||||||
|
correctCount = voteDoc.data.filter((v) => v.result).length;
|
||||||
|
judgment = judgeByCounts(correctCount, threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = await applyDailyJudgmentTx(uid, date, {
|
||||||
|
judgment,
|
||||||
|
correctCount,
|
||||||
|
completedCount,
|
||||||
|
computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tx.skippedByGuard) {
|
||||||
|
logger.info(`judgeDay: ${uid} ${date} already judged, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setDay(uid, date, {
|
||||||
|
...voteDoc,
|
||||||
|
judgment,
|
||||||
|
correctCount,
|
||||||
|
completedCount,
|
||||||
|
streakAfter: tx.streakAfter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일요일 아카이브 직후 호출되어, 해당 주(화~일) 6일의 판정이 모두
|
||||||
|
* `success|perfect|skip` 이면 주간 마스터 티켓을 지급한다.
|
||||||
|
*
|
||||||
|
* @param uid - 유저 ID
|
||||||
|
* @param sundayDate - 방금 아카이브/판정이 끝난 일요일 날짜
|
||||||
|
*/
|
||||||
|
export async function judgeWeekIfNeeded(
|
||||||
|
uid: string,
|
||||||
|
sundayDate: DateString
|
||||||
|
): Promise<void> {
|
||||||
|
const tuesday = tuesdayOf(sundayDate);
|
||||||
|
const entries = await getRange(uid, tuesday, sundayDate);
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
|
const byDate = new Map(entries.map((e) => [e.date, e.doc] as const));
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const d = addDaysKst(tuesday, i);
|
||||||
|
const doc = byDate.get(d);
|
||||||
|
if (!doc || !doc.judgment) return; // 판정 누락
|
||||||
|
if (doc.judgment === "fail") return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const granted = await applyWeeklyMasterTx(uid, tuesday);
|
||||||
|
if (granted) {
|
||||||
|
logger.info(`weekly-master granted: uid=${uid} tuesday=${tuesday}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,16 @@
|
|||||||
import {rtdb} from "../firebase.js";
|
import {rtdb} from "../firebase.js";
|
||||||
import {HttpError} from "../middleware/errors.js";
|
import {HttpError} from "../middleware/errors.js";
|
||||||
import {computeLevel} from "../constants/levels.js";
|
import {computeLevel} from "../constants/levels.js";
|
||||||
|
import {tierOf} from "../constants/tiers.js";
|
||||||
import {getAll, getDay} from "../repositories/voteHistoryRepository.js";
|
import {getAll, getDay} from "../repositories/voteHistoryRepository.js";
|
||||||
|
import {getUser} from "../repositories/userRepository.js";
|
||||||
import type {StatsResponse, VoteHistoryDoc} from "../types/panit.js";
|
import type {StatsResponse, VoteHistoryDoc} from "../types/panit.js";
|
||||||
import {
|
import {
|
||||||
parseDateString,
|
parseDateString,
|
||||||
toDateString,
|
toDateString,
|
||||||
startOfDayKst,
|
startOfDayKst,
|
||||||
todayKst,
|
todayKst,
|
||||||
|
tuesdayOf as tuesdayOfUtil,
|
||||||
type DateString,
|
type DateString,
|
||||||
} from "../types/dateString.js";
|
} from "../types/dateString.js";
|
||||||
|
|
||||||
@ -51,12 +54,7 @@ function parseYmd(date: DateString): { y: number; m: number; d: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 해당 날짜가 속한 화~월 주의 화요일을 반환한다. */
|
/** 해당 날짜가 속한 화~월 주의 화요일을 반환한다. */
|
||||||
function tuesdayOf(date: DateString): DateString {
|
const tuesdayOf = tuesdayOfUtil;
|
||||||
const {y, m, d} = parseYmd(date);
|
|
||||||
const dow = new Date(y, m - 1, d).getDay() || 7; // 일=7, 월=1, 화=2, ...
|
|
||||||
const offset = (dow - 2 + 7) % 7;
|
|
||||||
return toDateString(new Date(y, m - 1, d - offset));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 날짜가 주어진 기간에 속하는지 판정한다.
|
* 날짜가 주어진 기간에 속하는지 판정한다.
|
||||||
@ -171,7 +169,7 @@ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc
|
|||||||
* @param period - 레벨·예측 수 집계에 사용할 기간
|
* @param period - 레벨·예측 수 집계에 사용할 기간
|
||||||
*/
|
*/
|
||||||
async function computeStats(uid: string, period: Period): Promise<StatsResponse> {
|
async function computeStats(uid: string, period: Period): Promise<StatsResponse> {
|
||||||
const all = await getAll(uid);
|
const [all, user] = await Promise.all([getAll(uid), getUser(uid)]);
|
||||||
const overall = aggregate(all);
|
const overall = aggregate(all);
|
||||||
|
|
||||||
const today = todayKst();
|
const today = todayKst();
|
||||||
@ -194,12 +192,17 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
|
|||||||
const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period));
|
const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period));
|
||||||
const periodAgg = aggregate(periodEntries);
|
const periodAgg = aggregate(periodEntries);
|
||||||
|
|
||||||
const streakDays = computeStreak(all);
|
const storedStreak = user?.currentStreak;
|
||||||
|
const streakDays = storedStreak ?? computeStreak(all);
|
||||||
|
const highestStreak = user?.highestStreak ?? streakDays;
|
||||||
|
const tierPoints = user?.tierPoints ?? 0;
|
||||||
|
const tickets = user?.tickets ?? {dailyAllKill: 0, weeklyMaster: 0};
|
||||||
const weeklyResults = weeklyResultsOf(all);
|
const weeklyResults = weeklyResultsOf(all);
|
||||||
const {level, progress} = computeLevel(periodAgg.total);
|
const {level, progress} = computeLevel(periodAgg.total);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streakDays,
|
streakDays,
|
||||||
|
highestStreak,
|
||||||
weeklyResults,
|
weeklyResults,
|
||||||
winRates: {
|
winRates: {
|
||||||
overall: rate(overall.correct, overall.total),
|
overall: rate(overall.correct, overall.total),
|
||||||
@ -212,6 +215,9 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
|
|||||||
weeklyPredictions: weekly.total,
|
weeklyPredictions: weekly.total,
|
||||||
currentLevel: level,
|
currentLevel: level,
|
||||||
progress,
|
progress,
|
||||||
|
tier: tierOf(tierPoints),
|
||||||
|
tierPoints,
|
||||||
|
tickets,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,3 +47,23 @@ export function daysAgoKst(n: number): DateString {
|
|||||||
export function startOfDayKst(date: DateString): Date {
|
export function startOfDayKst(date: DateString): Date {
|
||||||
return new Date(Date.parse(`${date}T00:00:00+09:00`));
|
return new Date(Date.parse(`${date}T00:00:00+09:00`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 해당 KST 날짜가 속한 화~월 주의 화요일을 반환한다. */
|
||||||
|
export function tuesdayOf(date: DateString): DateString {
|
||||||
|
const [y, m, d] = date.split("-").map(Number);
|
||||||
|
const dow = new Date(y, m - 1, d).getDay() || 7; // 일=7, 월=1, 화=2
|
||||||
|
const offset = (dow - 2 + 7) % 7;
|
||||||
|
return toDateString(new Date(y, m - 1, d - offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `date`의 요일 (일=0, 월=1, ... 토=6). KST 기준. */
|
||||||
|
export function dayOfWeekKst(date: DateString): number {
|
||||||
|
const [y, m, d] = date.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d).getDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `date`에 `delta` 일을 더한 KST 날짜를 반환. */
|
||||||
|
export function addDaysKst(date: DateString, delta: number): DateString {
|
||||||
|
const base = startOfDayKst(date).getTime();
|
||||||
|
return toDateString(new Date(base + delta * 24 * 60 * 60 * 1000));
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import type { Timestamp } from "firebase-admin/firestore";
|
import type { Timestamp } from "firebase-admin/firestore";
|
||||||
|
import type { DateString } from "./dateString.js";
|
||||||
|
|
||||||
export type Provider = "google" | "apple";
|
export type Provider = "google" | "apple";
|
||||||
|
|
||||||
|
export type DailyJudgment = "perfect" | "success" | "fail" | "skip";
|
||||||
|
|
||||||
|
export type TierName = "bronze" | "silver" | "gold" | "platinum" | "diamond";
|
||||||
export type GameStatus = "scheduled" | "live" | "completed" | "cancelled";
|
export type GameStatus = "scheduled" | "live" | "completed" | "cancelled";
|
||||||
|
|
||||||
export enum TeamCode {
|
export enum TeamCode {
|
||||||
@ -22,6 +27,11 @@ export enum KnowledgeLevel {
|
|||||||
Expert = "expert",
|
Expert = "expert",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TicketMap {
|
||||||
|
dailyAllKill: number;
|
||||||
|
weeklyMaster: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -30,6 +40,12 @@ export interface User {
|
|||||||
favoriteTeamCode?: TeamCode;
|
favoriteTeamCode?: TeamCode;
|
||||||
knowledgeLevel: KnowledgeLevel;
|
knowledgeLevel: KnowledgeLevel;
|
||||||
createdAt: Timestamp;
|
createdAt: Timestamp;
|
||||||
|
currentStreak?: number;
|
||||||
|
highestStreak?: number;
|
||||||
|
tierPoints?: number;
|
||||||
|
tickets?: TicketMap;
|
||||||
|
lastJudgedDate?: DateString;
|
||||||
|
lastWeeklyMasterTuesday?: DateString;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
@ -48,10 +64,15 @@ export interface VoteEntry {
|
|||||||
|
|
||||||
export interface VoteHistoryDoc {
|
export interface VoteHistoryDoc {
|
||||||
data: Array<{ gameId: string; team: string; result: boolean }>;
|
data: Array<{ gameId: string; team: string; result: boolean }>;
|
||||||
|
judgment?: DailyJudgment;
|
||||||
|
correctCount?: number;
|
||||||
|
completedCount?: number;
|
||||||
|
streakAfter?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatsResponse {
|
export interface StatsResponse {
|
||||||
streakDays: number;
|
streakDays: number;
|
||||||
|
highestStreak: number;
|
||||||
weeklyResults: Array<boolean | null>;
|
weeklyResults: Array<boolean | null>;
|
||||||
winRates: {
|
winRates: {
|
||||||
overall: number;
|
overall: number;
|
||||||
@ -64,5 +85,8 @@ export interface StatsResponse {
|
|||||||
weeklyPredictions: number;
|
weeklyPredictions: number;
|
||||||
currentLevel: number;
|
currentLevel: number;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
tier: TierName;
|
||||||
|
tierPoints: number;
|
||||||
|
tickets: TicketMap;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|||||||
362
tests/services/judgmentService.test.ts
Normal file
362
tests/services/judgmentService.test.ts
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { Timestamp } from "firebase-admin/firestore";
|
||||||
|
import { firestore } from "../../src/firebase.js";
|
||||||
|
import {
|
||||||
|
judgeDay,
|
||||||
|
judgeWeekIfNeeded,
|
||||||
|
} from "../../src/services/judgmentService.js";
|
||||||
|
import { setDay } from "../../src/repositories/voteHistoryRepository.js";
|
||||||
|
import type { DateString } from "../../src/types/dateString.js";
|
||||||
|
import type {
|
||||||
|
DailyJudgment,
|
||||||
|
Game,
|
||||||
|
GameStatus,
|
||||||
|
User,
|
||||||
|
VoteHistoryDoc,
|
||||||
|
} from "../../src/types/panit.js";
|
||||||
|
|
||||||
|
const uid = "judge-uid";
|
||||||
|
|
||||||
|
/** 2026-04-14는 화요일 (KST) */
|
||||||
|
const TUE: DateString = "2026-04-14" as DateString;
|
||||||
|
const WED: DateString = "2026-04-15" as DateString;
|
||||||
|
const THU: DateString = "2026-04-16" as DateString;
|
||||||
|
const FRI: DateString = "2026-04-17" as DateString;
|
||||||
|
const SAT: DateString = "2026-04-18" as DateString;
|
||||||
|
const SUN: DateString = "2026-04-19" as DateString;
|
||||||
|
|
||||||
|
function makeGame(
|
||||||
|
date: DateString,
|
||||||
|
status: GameStatus,
|
||||||
|
homeTeamCode: string,
|
||||||
|
winner: string | null = null
|
||||||
|
): Game {
|
||||||
|
const [y, m, d] = date.split("-").map(Number);
|
||||||
|
const doc: Game = {
|
||||||
|
time: Timestamp.fromDate(new Date(Date.UTC(y, m - 1, d, 9, 0))),
|
||||||
|
stadium: "잠실",
|
||||||
|
status,
|
||||||
|
homeTeamCode,
|
||||||
|
awayTeamCode: "HT",
|
||||||
|
};
|
||||||
|
if (winner) doc.winningTeamCode = winner;
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedGames(
|
||||||
|
date: DateString,
|
||||||
|
specs: Array<{ status: GameStatus; winner?: string }>
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < specs.length; i++) {
|
||||||
|
const spec = specs[i];
|
||||||
|
const gameId = `${date.replace(/-/g, "")}G${i}`;
|
||||||
|
const game = makeGame(date, spec.status, "LG", spec.winner ?? null);
|
||||||
|
await firestore.collection("games").doc(gameId).set(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function voteDocOf(correct: number, total: number): VoteHistoryDoc {
|
||||||
|
const data = [];
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
data.push({
|
||||||
|
gameId: `g${i}`,
|
||||||
|
team: "LG",
|
||||||
|
result: i < correct,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedUser(patch: Partial<User> = {}): Promise<void> {
|
||||||
|
await firestore
|
||||||
|
.collection("users")
|
||||||
|
.doc(uid)
|
||||||
|
.set(
|
||||||
|
{
|
||||||
|
displayName: "judge",
|
||||||
|
email: "j@e.com",
|
||||||
|
provider: "google",
|
||||||
|
knowledgeLevel: "beginner",
|
||||||
|
createdAt: Timestamp.now(),
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUser(): Promise<Partial<User>> {
|
||||||
|
const snap = await firestore.collection("users").doc(uid).get();
|
||||||
|
return (snap.data() ?? {}) as Partial<User>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readVoteHistory(date: DateString): Promise<VoteHistoryDoc> {
|
||||||
|
const snap = await firestore
|
||||||
|
.collection("users")
|
||||||
|
.doc(uid)
|
||||||
|
.collection("voteHistory")
|
||||||
|
.doc(date)
|
||||||
|
.get();
|
||||||
|
return (snap.data() ?? { data: [] }) as VoteHistoryDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedJudgedHistory(
|
||||||
|
date: DateString,
|
||||||
|
judgment: DailyJudgment
|
||||||
|
): Promise<void> {
|
||||||
|
await setDay(uid, date, { data: [], judgment });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("judgmentService (Firestore emulator)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await firestore.recursiveDelete(firestore.collection("users"));
|
||||||
|
await firestore.recursiveDelete(firestore.collection("games"));
|
||||||
|
await seedUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("judgeDay — 정상 5경기", () => {
|
||||||
|
it("5경기 모두 종료·5적중이면 perfect 판정과 dailyAllKill 티켓 지급", async () => {
|
||||||
|
await seedGames(TUE, [
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
]);
|
||||||
|
const vote = voteDocOf(5, 5);
|
||||||
|
await setDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
await judgeDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
const user = await readUser();
|
||||||
|
const hist = await readVoteHistory(TUE);
|
||||||
|
|
||||||
|
expect(hist.judgment).toBe("perfect");
|
||||||
|
expect(hist.correctCount).toBe(5);
|
||||||
|
expect(hist.completedCount).toBe(5);
|
||||||
|
expect(hist.streakAfter).toBe(1);
|
||||||
|
expect(user.currentStreak).toBe(1);
|
||||||
|
expect(user.highestStreak).toBe(1);
|
||||||
|
expect(user.tickets?.dailyAllKill).toBe(1);
|
||||||
|
expect(user.tickets?.weeklyMaster).toBe(0);
|
||||||
|
expect(user.tierPoints).toBe(50); // 5 * 10, streak 1이라 보너스 0
|
||||||
|
expect(user.lastJudgedDate).toBe(TUE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("5경기·3적중은 success, 스트릭 +1, 티켓 미지급", async () => {
|
||||||
|
await seedGames(TUE, [
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
]);
|
||||||
|
const vote = voteDocOf(3, 5);
|
||||||
|
await setDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
await judgeDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
const user = await readUser();
|
||||||
|
const hist = await readVoteHistory(TUE);
|
||||||
|
|
||||||
|
expect(hist.judgment).toBe("success");
|
||||||
|
expect(user.currentStreak).toBe(1);
|
||||||
|
expect(user.tickets?.dailyAllKill).toBe(0);
|
||||||
|
expect(user.tierPoints).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("5경기·2적중은 fail, 스트릭 0 유지, 포인트 불변", async () => {
|
||||||
|
await seedGames(TUE, [
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
]);
|
||||||
|
await seedUser({ currentStreak: 3, highestStreak: 3, tierPoints: 100 });
|
||||||
|
const vote = voteDocOf(2, 5);
|
||||||
|
await setDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
await judgeDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
const user = await readUser();
|
||||||
|
expect(user.currentStreak).toBe(0);
|
||||||
|
expect(user.highestStreak).toBe(3); // 기존 최고치 유지
|
||||||
|
expect(user.tierPoints).toBe(100); // 감점 없음
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("judgeDay — 일부 취소 (thresholds 일반화)", () => {
|
||||||
|
it("3 completed + 2 cancelled, 2적중이면 success (success=2, perfect=3)", async () => {
|
||||||
|
await seedGames(TUE, [
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "cancelled" },
|
||||||
|
{ status: "cancelled" },
|
||||||
|
]);
|
||||||
|
const vote = voteDocOf(2, 3);
|
||||||
|
await setDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
await judgeDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
const hist = await readVoteHistory(TUE);
|
||||||
|
expect(hist.judgment).toBe("success");
|
||||||
|
expect(hist.completedCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("3 completed, 3적중이면 perfect → dailyAllKill 지급", async () => {
|
||||||
|
await seedGames(TUE, [
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
]);
|
||||||
|
const vote = voteDocOf(3, 3);
|
||||||
|
await setDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
await judgeDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
const user = await readUser();
|
||||||
|
const hist = await readVoteHistory(TUE);
|
||||||
|
expect(hist.judgment).toBe("perfect");
|
||||||
|
expect(user.tickets?.dailyAllKill).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("4 completed, 2적중이면 success (success=2, perfect=4)", async () => {
|
||||||
|
await seedGames(TUE, [
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
]);
|
||||||
|
const vote = voteDocOf(2, 4);
|
||||||
|
await setDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
await judgeDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
const hist = await readVoteHistory(TUE);
|
||||||
|
expect(hist.judgment).toBe("success");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("judgeDay — skip (completed ≤ 2)", () => {
|
||||||
|
it("2 completed만 있으면 skip, 스트릭 유지, 포인트 불변", async () => {
|
||||||
|
await seedUser({ currentStreak: 4, tierPoints: 80, highestStreak: 4 });
|
||||||
|
await seedGames(TUE, [
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "cancelled" },
|
||||||
|
]);
|
||||||
|
const vote = voteDocOf(1, 2);
|
||||||
|
await setDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
await judgeDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
const user = await readUser();
|
||||||
|
const hist = await readVoteHistory(TUE);
|
||||||
|
|
||||||
|
expect(hist.judgment).toBe("skip");
|
||||||
|
expect(hist.streakAfter).toBe(4);
|
||||||
|
expect(user.currentStreak).toBe(4);
|
||||||
|
expect(user.tierPoints).toBe(80);
|
||||||
|
expect(user.tickets?.dailyAllKill).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("judgeDay — 멱등성", () => {
|
||||||
|
it("같은 날짜에 두 번 호출해도 포인트·스트릭·티켓이 두 번 누적되지 않는다", async () => {
|
||||||
|
await seedGames(TUE, [
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
{ status: "completed", winner: "LG" },
|
||||||
|
]);
|
||||||
|
const vote = voteDocOf(5, 5);
|
||||||
|
await setDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
await judgeDay(uid, TUE, vote);
|
||||||
|
await judgeDay(uid, TUE, vote);
|
||||||
|
|
||||||
|
const user = await readUser();
|
||||||
|
expect(user.currentStreak).toBe(1);
|
||||||
|
expect(user.tickets?.dailyAllKill).toBe(1);
|
||||||
|
expect(user.tierPoints).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("judgeWeekIfNeeded", () => {
|
||||||
|
async function seedWeekJudgments(
|
||||||
|
judgments: Record<DateString, DailyJudgment>
|
||||||
|
): Promise<void> {
|
||||||
|
for (const [date, j] of Object.entries(judgments)) {
|
||||||
|
await seedJudgedHistory(date as DateString, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("화~일 6일 전부 success|perfect|skip 이면 weeklyMaster 지급", async () => {
|
||||||
|
await seedWeekJudgments({
|
||||||
|
[TUE]: "success",
|
||||||
|
[WED]: "perfect",
|
||||||
|
[THU]: "skip",
|
||||||
|
[FRI]: "success",
|
||||||
|
[SAT]: "success",
|
||||||
|
[SUN]: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
await judgeWeekIfNeeded(uid, SUN);
|
||||||
|
|
||||||
|
const user = await readUser();
|
||||||
|
expect(user.tickets?.weeklyMaster).toBe(1);
|
||||||
|
expect(user.lastWeeklyMasterTuesday).toBe(TUE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("한 날이라도 fail이면 지급 안 됨", async () => {
|
||||||
|
await seedWeekJudgments({
|
||||||
|
[TUE]: "success",
|
||||||
|
[WED]: "fail",
|
||||||
|
[THU]: "success",
|
||||||
|
[FRI]: "success",
|
||||||
|
[SAT]: "success",
|
||||||
|
[SUN]: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
await judgeWeekIfNeeded(uid, SUN);
|
||||||
|
|
||||||
|
const user = await readUser();
|
||||||
|
expect(user.tickets?.weeklyMaster ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("판정 이력이 누락된 날이 있으면 지급 안 됨", async () => {
|
||||||
|
await seedWeekJudgments({
|
||||||
|
[TUE]: "success",
|
||||||
|
[WED]: "success",
|
||||||
|
// THU 누락
|
||||||
|
[FRI]: "success",
|
||||||
|
[SAT]: "success",
|
||||||
|
[SUN]: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
await judgeWeekIfNeeded(uid, SUN);
|
||||||
|
|
||||||
|
const user = await readUser();
|
||||||
|
expect(user.tickets?.weeklyMaster ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("같은 주 재실행 시 중복 지급되지 않는다 (멱등)", async () => {
|
||||||
|
await seedWeekJudgments({
|
||||||
|
[TUE]: "success",
|
||||||
|
[WED]: "success",
|
||||||
|
[THU]: "success",
|
||||||
|
[FRI]: "success",
|
||||||
|
[SAT]: "success",
|
||||||
|
[SUN]: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
await judgeWeekIfNeeded(uid, SUN);
|
||||||
|
await judgeWeekIfNeeded(uid, SUN);
|
||||||
|
|
||||||
|
const user = await readUser();
|
||||||
|
expect(user.tickets?.weeklyMaster).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user