Passport Tier 팬덤 단위 · 활동 Badge · 진화 Emblem · Eclipse 참여 기록
This commit is contained in:
@@ -245,11 +245,6 @@ describe('대사 — 별첨 1 ② 3자 교차 대조', () => {
|
||||
await getFirestore().doc(`users/${UID}`).set({ userLevel: 50 }, { merge: true });
|
||||
expect(await failedNames()).toEqual(['level-vs-exp']);
|
||||
|
||||
// 레벨을 되돌리고 EXP 를 Lv.30 이상으로 올렸는데 Passport 가 SILVER 로 남은 상태(강등 · 누락).
|
||||
await getFirestore().doc(`users/${UID}`).set({
|
||||
userExp: 500_000, userLevel: 31, passportTier: 'SILVER',
|
||||
}, { merge: true });
|
||||
expect(await failedNames()).toEqual(['passport-monotonic']);
|
||||
});
|
||||
|
||||
it('지갑을 몰래 고치면 대사가 잡아낸다', async () => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest';
|
||||
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
import { awardActivityBadges, refreshPassport } from '../src/account/passport.js';
|
||||
import { awardEvolutionEmblems } from '../src/fandom/evolution.js';
|
||||
import { grantPop } from '../src/ledger/ledger.js';
|
||||
|
||||
/** 팬덤별 Passport Tier · 활동 Badge · 진화 Emblem — 상세정의 4장 · 22장 · 9/21 5절 · 10/3 #10. */
|
||||
const uid = 'test-passport-user';
|
||||
const other = 'test-passport-other';
|
||||
const a = 'test-passport-a';
|
||||
const b = 'test-passport-b';
|
||||
const DAY = 86_400_000;
|
||||
|
||||
async function clean() {
|
||||
const db = getFirestore();
|
||||
for (const path of [`users/${uid}`, `users/${other}`, `fandoms/${a}`, `fandoms/${b}`]) {
|
||||
await db.recursiveDelete(db.doc(path));
|
||||
}
|
||||
await db.recursiveDelete(db.doc('config/passportTierRules'));
|
||||
}
|
||||
beforeAll(() => { bootAdmin(); });
|
||||
beforeEach(async () => {
|
||||
await clean();
|
||||
const db = getFirestore();
|
||||
await db.doc(`users/${uid}`).set({ userLevel: 30, fandomIds: [a, b], primaryFandomId: a });
|
||||
for (const f of [a, b]) {
|
||||
await db.doc(`fandoms/${f}/members/${uid}`).set({
|
||||
uid, fandomId: f, memberNo: 7, contributedPop: 0,
|
||||
joinedAt: Timestamp.fromMillis(Date.now() - 40 * DAY),
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(async () => { await clean(); await shutdownAdmin(); });
|
||||
|
||||
it('활동 카운터가 7단계 문턱을 넘으면 Badge 단계가 오르고 내려가지 않는다', async () => {
|
||||
const db = getFirestore();
|
||||
await awardActivityBadges(uid, { health: 120, bpm: 5 });
|
||||
const health = await db.doc(`users/${uid}/badges/activity_health`).get();
|
||||
expect(health.get('stage')).toBe(2);
|
||||
expect((await db.doc(`users/${uid}/badges/activity_bpm`).get()).exists).toBe(false);
|
||||
await awardActivityBadges(uid, { health: 10 });
|
||||
expect((await db.doc(`users/${uid}/badges/activity_health`).get()).get('stage')).toBe(2);
|
||||
});
|
||||
|
||||
it('Tier 는 팬덤별 — 같은 사용자라도 기여가 다른 팬덤은 Tier 가 따로다', async () => {
|
||||
const db = getFirestore();
|
||||
await awardActivityBadges(uid, { health: 500, community: 500 });
|
||||
await db.doc('config/passportTierRules').set({ rules: [
|
||||
{ tier: 'GOLD', minUserLevel: 30, minActiveDays: 30, minBadges: 1, minBadgeStage: 1, minContributedPop: 0, enabled: true },
|
||||
{ tier: 'DIAMOND', minUserLevel: 30, minActiveDays: 30, minBadges: 2, minBadgeStage: 3, minContributedPop: 1_000, enabled: true },
|
||||
] });
|
||||
await db.doc(`fandoms/${b}/members/${uid}`).update({ contributedPop: 1_000 });
|
||||
expect(await refreshPassport(uid, a)).toBe('GOLD');
|
||||
expect(await refreshPassport(uid, b)).toBe('DIAMOND');
|
||||
const pa = await db.doc(`users/${uid}/passports/${a}`).get();
|
||||
expect(pa.get('tier')).toBe('GOLD');
|
||||
expect(pa.get('memberNo')).toBe(7);
|
||||
expect(pa.get('activeDays')).toBeGreaterThanOrEqual(39);
|
||||
// users 문서에는 단일 Tier 를 두지 않는다.
|
||||
expect((await db.doc(`users/${uid}`).get()).get('passportTier')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('탈퇴 후에도 Passport 는 남고 강등하지 않으며 재가입하면 이어 쓴다', async () => {
|
||||
const db = getFirestore();
|
||||
await awardActivityBadges(uid, { health: 10 });
|
||||
expect(await refreshPassport(uid, a)).toBe('GOLD');
|
||||
await db.doc(`fandoms/${a}/members/${uid}`).delete();
|
||||
expect(await refreshPassport(uid, a)).toBe('GOLD');
|
||||
await db.doc(`users/${uid}`).update({ userLevel: 1 });
|
||||
await db.doc(`fandoms/${a}/members/${uid}`).set({ uid, memberNo: 99, contributedPop: 0, joinedAt: Timestamp.now() });
|
||||
expect(await refreshPassport(uid, a)).toBe('GOLD');
|
||||
const pa = await db.doc(`users/${uid}/passports/${a}`).get();
|
||||
// 첫 가입 순번 · 시각을 그대로 둔다 — 새 Passport 로 초기화하지 않는다.
|
||||
expect(pa.get('memberNo')).toBe(7);
|
||||
expect(pa.get('activeDays')).toBeGreaterThanOrEqual(39);
|
||||
});
|
||||
|
||||
it('인정 활동은 FPI 축별 활동 카운터를 올린다', async () => {
|
||||
const db = getFirestore();
|
||||
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QUEST_ATTEND',
|
||||
growthSource: 'quest_reward', idempotencyKey: 'passport:attend' });
|
||||
// 지급만으로는 FPI 활동이 아니다 — 카운터는 FPI 이벤트를 쓰는 경로가 올린다.
|
||||
const { writeFpiEvent } = await import('../src/ledger/fpi.js');
|
||||
await db.runTransaction(async (tx) => {
|
||||
const user = await tx.get(db.doc(`users/${uid}`));
|
||||
writeFpiEvent(tx, { uid, fandomId: a, axis: 'health', units: 2, activity: 'steps',
|
||||
sourceId: 'passport-s1', occurredAt: new Date() }, user);
|
||||
});
|
||||
expect((await db.doc(`users/${uid}`).get()).get('activityCounts')).toEqual({ health: 2 });
|
||||
});
|
||||
|
||||
it('진화 기념 Emblem 은 그 주기에 성장 기여한 사용자에게만, 참여자 알림과 함께', async () => {
|
||||
const db = getFirestore();
|
||||
const since = Timestamp.fromMillis(Date.now() - DAY);
|
||||
await db.doc(`users/${uid}/growthEvents/g1`).set({
|
||||
fandomIdAtPayout: a, amount: 10, reversalOf: null, createdAt: Timestamp.now(),
|
||||
});
|
||||
// 주기 이전 기여 · 다른 팬덤 기여는 제외.
|
||||
await db.doc(`users/${other}/growthEvents/g0`).set({
|
||||
fandomIdAtPayout: a, amount: 10, reversalOf: null, createdAt: Timestamp.fromMillis(Date.now() - 3 * DAY),
|
||||
});
|
||||
await db.doc(`users/${other}/growthEvents/g2`).set({
|
||||
fandomIdAtPayout: b, amount: 10, reversalOf: null, createdAt: Timestamp.now(),
|
||||
});
|
||||
const uids = await awardEvolutionEmblems({
|
||||
fandomId: a, name: '테스트', fromStage: 1, toStage: 2, since, until: Timestamp.fromMillis(Date.now() + 1000),
|
||||
});
|
||||
expect(uids).toEqual([uid]);
|
||||
const emblem = await db.doc(`users/${uid}/badges/evolution_${a}_2`).get();
|
||||
expect(emblem.get('kind')).toBe('evolution');
|
||||
expect(emblem.get('toStage')).toBe(2);
|
||||
expect((await db.doc(`users/${other}/badges/evolution_${a}_2`).get()).exists).toBe(false);
|
||||
const note = await db.doc(`users/${uid}/notifications/evolution_${a}_2`).get();
|
||||
expect(note.get('body')).toContain('당신도 이 진화에 함께했습니다');
|
||||
// 재전달에도 한 번.
|
||||
await awardEvolutionEmblems({
|
||||
fandomId: a, fromStage: 1, toStage: 2, since, until: Timestamp.fromMillis(Date.now() + 1000),
|
||||
});
|
||||
expect((await db.collection(`users/${uid}/badges`).get()).size).toBe(1);
|
||||
});
|
||||
@@ -76,3 +76,29 @@ it('내 활동은 호출자와 지급 당시 팬덤으로 제한하고 다른
|
||||
await expect(call({ fandomId, scope: 'personal', cursor: 'other-fandom' })).rejects.toThrow();
|
||||
await expect(call({ fandomId, scope: 'all-users' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('내 기록 첫 장에 PASSPORT Stamp(진화 Emblem · 최초 100명 · Eclipse 성공 참여)를 함께 준다', async () => {
|
||||
const db = getFirestore();
|
||||
const { recordEclipseSuccess } = await import('../src/fandom/fandom.js');
|
||||
const round = db.doc(`fandoms/${fandomId}/eclipseRounds/r1`);
|
||||
await round.collection('participants').doc(uid).set({ qualified: true, validShakes: 60 });
|
||||
await round.collection('participants').doc('not-qualified').set({ qualified: false, validShakes: 10 });
|
||||
expect(await recordEclipseSuccess(fandomId, round)).toBe(1);
|
||||
expect(await recordEclipseSuccess(fandomId, round)).toBe(1);
|
||||
expect((await db.collection(`users/${uid}/passportHistory`).get()).size).toBe(1);
|
||||
expect((await db.doc(`users/not-qualified/passportHistory/eclipse_${fandomId}_r1`).get()).exists).toBe(false);
|
||||
await db.doc(`users/${uid}/badges/evolution_${fandomId}_2`).set({
|
||||
kind: 'evolution', fandomId, toStage: 2, awardedAt: Timestamp.fromMillis(50_000) });
|
||||
await db.doc(`users/${uid}/badges/founder100_${fandomId}`).set({
|
||||
kind: 'founder100', fandomId, memberNo: 7, awardedAt: Timestamp.fromMillis(1_000) });
|
||||
await db.doc(`users/${uid}/badges/founder100_other`).set({
|
||||
kind: 'founder100', fandomId: 'other', memberNo: 1, awardedAt: Timestamp.fromMillis(1_000) });
|
||||
const result = await call({ fandomId, scope: 'personal' });
|
||||
const kinds = result.items.map((item) => item.kind);
|
||||
expect(kinds[0]).toBe('eclipse_success');
|
||||
expect(kinds).toContain('emblem_evolution');
|
||||
expect(kinds.filter((k) => k === 'badge_founder100')).toHaveLength(1);
|
||||
// 팬덤 범위에는 섞지 않는다.
|
||||
expect((await call({ fandomId })).items.every((item) => item.kind === 'evolution')).toBe(true);
|
||||
await db.recursiveDelete(db.doc('users/not-qualified'));
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ import { txIdOf, lotIdOf, deviceHashOf } from '../src/core/ids.js';
|
||||
import { dayKey, isPlausibleOccurredAt } from '../src/core/time.js';
|
||||
import { DEFAULT_POLICY, FPI_AXES, weightsAreUsable } from '../src/core/config.js';
|
||||
import { CONTRIBUTION_REASONS } from '../src/ledger/ledger.js';
|
||||
import { expForLevel, levelOf, passportTierOf, titleOf } from '../src/core/level.js';
|
||||
import { expForLevel, levelOf, titleOf } from '../src/core/level.js';
|
||||
import { badgeStageOf, BADGE_STAGE_THRESHOLDS, DEFAULT_TIER_RULES, tierOf } from '../src/account/passport.js';
|
||||
import { FPI_UNIT_FACTOR, rankWithTies } from '../src/ledger/fpi.js';
|
||||
import { fnIapRefundWebhook, fnSubscriptionWebhook } from '../src/payment/iap.js';
|
||||
import {
|
||||
@@ -223,7 +224,7 @@ describe('User Level · 칭호 · Passport — C-18 · B-65 · B-66', () => {
|
||||
it('신규 회원은 Lv.1 GLOW · SILVER 다', () => {
|
||||
expect(levelOf(0)).toBe(1);
|
||||
expect(titleOf(levelOf(0))).toBe('GLOW');
|
||||
expect(passportTierOf(1)).toBe('SILVER');
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { userLevel: 1, activeDays: 0, badgeStages: [], contributedPop: 0 })).toBe('SILVER');
|
||||
});
|
||||
|
||||
it('레벨은 EXP 요구량 경계에서 정확히 오른다', () => {
|
||||
@@ -250,13 +251,25 @@ describe('User Level · 칭호 · Passport — C-18 · B-65 · B-66', () => {
|
||||
expect(titleOf(levelOf(3_000))).toBe('GLOW');
|
||||
});
|
||||
|
||||
it('Passport 는 Gold Lv.30+ · Diamond Lv.70+ 이고 강등하지 않는다', () => {
|
||||
expect(passportTierOf(29)).toBe('SILVER');
|
||||
expect(passportTierOf(30)).toBe('GOLD');
|
||||
expect(passportTierOf(70)).toBe('DIAMOND');
|
||||
expect(passportTierOf(1, 'DIAMOND')).toBe('DIAMOND');
|
||||
expect(passportTierOf(40, 'DIAMOND')).toBe('DIAMOND');
|
||||
expect(passportTierOf(75, 'GOLD')).toBe('DIAMOND');
|
||||
it('Passport Tier 는 팬덤별 규칙(Level · 활동기간 · Badge · 누적기여)을 모두 채워야 오르고 강등하지 않는다', () => {
|
||||
const base = { userLevel: 30, activeDays: 30, badgeStages: [1], contributedPop: 0 };
|
||||
expect(tierOf(DEFAULT_TIER_RULES, base)).toBe('GOLD');
|
||||
// 하나라도 모자라면 그대로 — Level 만으로 오르지 않는다(상세정의 4장 · 10/3 #10).
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { ...base, userLevel: 29 })).toBe('SILVER');
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { ...base, activeDays: 29 })).toBe('SILVER');
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { ...base, badgeStages: [] })).toBe('SILVER');
|
||||
const diamond = { userLevel: 70, activeDays: 180, badgeStages: [3, 3], contributedPop: 100_000 };
|
||||
expect(tierOf(DEFAULT_TIER_RULES, diamond)).toBe('DIAMOND');
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { ...diamond, contributedPop: 99_999 })).toBe('GOLD');
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { ...base, userLevel: 1 }, 'DIAMOND')).toBe('DIAMOND');
|
||||
// 운영값이 규칙을 끄면 그 Tier 로 오르지 않는다.
|
||||
expect(tierOf(DEFAULT_TIER_RULES.map((r) => ({ ...r, enabled: false })), diamond)).toBe('SILVER');
|
||||
});
|
||||
|
||||
it('활동 Badge 는 7단계 10/100/500/1,500/4,000/10,000/30,000(상세정의 4장)', () => {
|
||||
expect(BADGE_STAGE_THRESHOLDS).toEqual([10, 100, 500, 1_500, 4_000, 10_000, 30_000]);
|
||||
expect([0, 9, 10, 99, 100, 1_500, 29_999, 30_000, 1e9].map((n) => badgeStageOf(BADGE_STAGE_THRESHOLDS, n)))
|
||||
.toEqual([0, 0, 1, 1, 2, 4, 6, 7, 7]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user