Kakao 로그인을 SDK와 서버 커스텀 토큰으로 · sFPI 일 집계 직후 Stage 승급 판정

This commit is contained in:
Sn-Kinos
2026-10-05 19:07:47 +09:00
parent 576dcfbdfd
commit 65669b969e
24 changed files with 1034 additions and 57 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore --project demo-fanuzu \"npm test\""
"test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore,auth --project demo-fanuzu \"npm test\""
},
"dependencies": {
"firebase-admin": "13.6.0",
+143
View File
@@ -0,0 +1,143 @@
import { getAuth } from 'firebase-admin/auth';
import { defineString } from 'firebase-functions/params';
import { onCall } from 'firebase-functions/v2/https';
import { str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
/**
* `fn-auth-kakao` — 카카오 로그인·SNS 연동을 Firebase 커스텀 토큰으로 잇는다.
*
* **왜 OIDC 가 아니라 이 경로인가.** Android 에서 Firebase OIDC(`oidc.kakao`) 웹 흐름은
* 「카카오톡으로 로그인」을 고르면 카카오톡 앱으로 넘어갔다가 콜백이 Custom Tab 이 아닌 일반
* Chrome 에서 열려 Firebase 핸들러가 「missing initial state」로 끝난다. 카카오에는 앱 전환을
* 막는 파라미터가 없어서, 앱이 카카오 공식 SDK 로 로그인하고 서버가 그 토큰을 검증해
* 커스텀 토큰을 내준다(Firebase 「Authenticate with Firebase Using a Custom Authentication
* System」).
*
* **사용자 식별은 기존 `oidc.kakao` 연결을 그대로 쓴다.** Firebase 사용자의 제공자 정보에
* `oidc.kakao` / 카카오 회원번호(OIDC `sub` 와 같은 값)를 남겨 두고 그것으로 찾는다. 그래서
* - OIDC 시절 가입자는 같은 카카오 계정으로 들어오면 **같은 UID** 로 로그인되고,
* - SNS 연동 화면(`linkedAccounts`)이 지금처럼 `oidc.kakao` 를 보고 「연결됨」을 그린다.
* 신규 가입자의 UID 는 `kakao:<회원번호>` 다(결정적 — 동시 첫 로그인이 겹쳐도 한 명).
*
* 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고
* `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
*/
export const KAKAO_PROVIDER_ID = 'oidc.kakao';
/** 카카오 Developers 「앱 ID」(숫자). 비밀값이 아니다 — 토큰이 우리 앱에서 발급됐는지 본다. */
const kakaoAppId = defineString('KAKAO_APP_ID', { default: '' });
export interface KakaoProfile {
/** 카카오 회원번호. */
id: string;
nickname?: string;
/** 동의했고 유효·인증된 이메일만. 없으면 이메일 비교를 건너뛴다. */
email?: string;
}
export const kakaoUidOf = (kakaoId: string) => `kakao:${kakaoId}`;
/**
* 카카오 액세스 토큰을 카카오 API 로 검증한다.
*
* 1. `/v1/user/access_token_info` — 유효성 + **우리 앱 ID 로 발급됐는가**. 남의 앱에서 받은
* 토큰을 들고 와 그 사람으로 로그인하는 토큰 치환을 막는 지점이다.
* 2. `/v2/user/me` — 닉네임·이메일. 회원번호가 1 과 같아야 한다.
*/
export async function verifyKakaoToken(accessToken: string, appId: string): Promise<KakaoProfile> {
if (!appId) fail(ERR.authFailed, '카카오 로그인 설정이 끝나지 않았습니다.');
const get = async (path: string) => {
const res = await fetch(`https://kapi.kakao.com${path}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.');
return (await res.json()) as Record<string, unknown>;
};
const info = await get('/v1/user/access_token_info');
if (String(info.app_id) !== appId) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.');
const me = await get('/v2/user/me');
if (String(me.id) !== String(info.id)) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.');
const account = (me.kakao_account ?? {}) as Record<string, unknown>;
const profile = (account.profile ?? {}) as Record<string, unknown>;
const email = account.is_email_valid === true && account.is_email_verified === true
&& typeof account.email === 'string' ? account.email : undefined;
const nickname = typeof profile.nickname === 'string' ? profile.nickname : undefined;
return {
id: String(info.id),
...(nickname ? { nickname } : {}),
...(email ? { email } : {}),
};
}
/** 이 카카오 계정이 붙은 Firebase UID. 없으면 null. */
async function ownerOf(kakaoId: string): Promise<string | null> {
try {
return (await getAuth().getUserByProviderUid(KAKAO_PROVIDER_ID, kakaoId)).uid;
} catch (e) {
if ((e as { code?: string }).code === 'auth/user-not-found') return null;
throw e;
}
}
function linkInfo(p: KakaoProfile) {
return {
providerId: KAKAO_PROVIDER_ID,
uid: p.id,
...(p.nickname ? { displayName: p.nickname } : {}),
...(p.email ? { email: p.email } : {}),
};
}
/** 로그인할 UID — 기존 연결이 있으면 그 UID, 없으면 `kakao:<id>` 를 만들고 연결한다. */
export async function resolveKakaoUid(p: KakaoProfile): Promise<string> {
const existing = await ownerOf(p.id);
if (existing) return existing;
const uid = kakaoUidOf(p.id);
const auth = getAuth();
try {
await auth.createUser({ uid, ...(p.nickname ? { displayName: p.nickname } : {}) });
} catch (e) {
// 같은 사람의 첫 로그인이 겹쳤다. 먼저 만든 쪽을 쓴다.
if ((e as { code?: string }).code !== 'auth/uid-already-exists') throw e;
}
await auth.updateUser(uid, { providerToLink: linkInfo(p) });
return uid;
}
/**
* SNS 연동 — 로그인한 [uid] 에 카카오를 붙인다. 새 계정을 만들지 않는다.
*
* 「동일 이메일만 연동」(명세 `554:22797`)은 붙이기 **전에** 서버에서 본다. 둘 중 하나라도
* 이메일이 없으면 건너뛴다(앱과 같은 규칙 — 카카오 이메일은 비즈 앱 동의가 있을 때만 온다).
*/
export async function linkKakao(uid: string, p: KakaoProfile): Promise<void> {
const owner = await ownerOf(p.id);
if (owner === uid) return;
if (owner) fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.');
const user = await getAuth().getUser(uid);
if (user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID)) {
fail(ERR.authAlreadyLinked, '이미 다른 카카오 계정이 연결되어 있습니다.');
}
const mine = user.email?.trim().toLowerCase();
const theirs = p.email?.trim().toLowerCase();
if (mine && theirs && mine !== theirs) {
fail(ERR.authCredentialInUse, '같은 이메일의 계정만 연결할 수 있습니다.', { reason: 'different-email' });
}
await getAuth().updateUser(uid, { providerToLink: linkInfo(p) });
}
export const fnAuthKakao = onCall(async (req) => {
const profile = await verifyKakaoToken(str(req.data, 'accessToken'), kakaoAppId.value());
if ((req.data as Record<string, unknown>).link === true) {
const uid = req.auth?.uid;
if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.');
await linkKakao(uid, profile);
return { linked: true };
}
const uid = await resolveKakaoUid(profile);
return { token: await getAuth().createCustomToken(uid) };
});
@@ -35,6 +35,9 @@ export const ERR = {
primaryCooldown: 'E-FDM-04',
permissionDenied: 'E-PERM-01',
authFailed: 'E-AUTH-03',
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다.
authCredentialInUse: 'E-AUTH-08',
authAlreadyLinked: 'E-AUTH-09',
// 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다.
trnSelfMark: 'E-TRN-01',
trnDailyCap: 'E-TRN-02',
@@ -76,6 +79,8 @@ const STATUS: Record<ErrorCode, Status> = {
'E-FDM-04': 'failed-precondition',
'E-PERM-01': 'permission-denied',
'E-AUTH-03': 'permission-denied',
'E-AUTH-08': 'already-exists',
'E-AUTH-09': 'already-exists',
'E-TRN-01': 'permission-denied',
'E-TRN-02': 'resource-exhausted',
'E-TRN-03': 'not-found',
@@ -1,4 +1,11 @@
import { onDocumentUpdated } from 'firebase-functions/v2/firestore';
import { onCall } from 'firebase-functions/v2/https';
import { FieldValue, getFirestore } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { callerUid, requireRole, str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
import { sendFandomPush } from '../notify/push.js';
/** v1.5 §2.1/§7: 확정 Stage 상승을 보존한다. 개인 참여/보상을 추정하지 않는다. */
export const fnPlanetEvolutionRecord = onDocumentUpdated(
@@ -30,3 +37,93 @@ export const fnPlanetEvolutionRecord = onDocumentUpdated(
}
},
);
/** 별첨 B **B-53** — 정식 행성 6 단계 명칭. 성운(Stage0)은 `fn-planet-create` 가 다룬다. */
const STAGE_NAMES = ['', '시작', '성장', '함께', '동행', '빛', '영원'];
const FINAL_STAGE = 6;
export interface Promotion { fandomId: string; fromStage: number; toStage: number }
/**
* 정식 행성 Stage 승급 판정 — 원장 **B-11 · B-53 · B-54**.
*
* - **누적 Growth POP(`growthPop`) 과 sFPI(`sfpi`) 를 동시에** 다음 Stage 진입값
* (`planetStages/{n}.requiredPop` · `requiredSfpi`, 설계서 §2.3 누계) 이상이어야 오른다.
* 하나만 채우면 그대로 대기한다.
* - 진입값이 누계라 초과분은 자연히 다음 Stage 진행도로 이월된다(B-54 Carry-over). 그래서
* 한 번의 판정에서 여러 Stage 를 채웠으면 **채운 가장 높은 Stage 까지** 올린다.
* - 강등하지 않는다(B-53 검증 기준 「Stage 강등 0건」). Stage6 이 마지막이다.
* - 다음 Stage 마스터 문서가 없거나 값이 비면 그 Stage 에서 멈춘다 — 빈 값을 0 으로 읽어
* 승급시키지 않는다.
*
* 트랜잭션 안에서 다시 읽고 쓰므로 같은 날 두 번 돌아도 두 번째는 할 일이 없다(멱등).
* 기록(`stateHistory`)은 `fnPlanetEvolutionRecord` 가, 연출은 앱이 `stage` 상승을 보고 띄운다.
*/
export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]> {
const db = getFirestore();
const stageDocs = await db.collection('planetStages').get();
const required = new Map<number, { pop: number; sfpi: number }>();
for (const doc of stageDocs.docs) {
const pop = doc.get('requiredPop');
const sfpi = doc.get('requiredSfpi');
if (Number.isFinite(pop) && Number.isFinite(sfpi)) required.set(Number(doc.id), { pop, sfpi });
}
const refs = fandomIds
? fandomIds.map((id) => db.doc(`fandoms/${id}`))
: (await db.collection('fandoms').where('stage', '>=', 1).select().get()).docs.map((d) => d.ref);
const promoted: Promotion[] = [];
for (const ref of refs) {
const result = await db.runTransaction(async (tx) => {
const snap = await tx.get(ref);
const from = snap.get('stage');
if (!Number.isInteger(from) || from < 1 || from >= FINAL_STAGE) return null;
const pop = Number(snap.get('growthPop') ?? 0);
const sfpi = Number(snap.get('sfpi') ?? 0);
let to = from as number;
for (let next = to + 1; next <= FINAL_STAGE; next++) {
const req = required.get(next);
if (!req || pop < req.pop || sfpi < req.sfpi) break;
to = next;
}
if (to === from) return null;
tx.update(ref, { stage: to, stageChangedAt: FieldValue.serverTimestamp() });
return { fandomId: ref.id, fromStage: from as number, toStage: to, name: snap.get('name') as string | undefined };
});
if (!result) continue;
const { name, ...promotion } = result;
promoted.push(promotion);
// 원장 C-78 ③ 성취 · C-77 「Evolution」 메시지. 발송 실패는 승급을 되돌리지 않는다.
await sendFandomPush({
fandomId: ref.id,
category: 'fandomEvent',
title: `${name ?? ref.id} 행성이 「${STAGE_NAMES[promotion.toStage]}」 단계로 진화했습니다`,
body: '당신도 이 진화에 함께했습니다. 행성에서 확인해 보세요.',
data: { kind: 'evolution', toStage: String(promotion.toStage) },
});
}
if (promoted.length > 0) logger.info('[evolution] Stage 승급', { promoted });
return promoted;
}
/**
* `fn-admin-planet-evolution-run` — 승급 판정을 지금 한 팬덤에 돌린다(master 전용).
*
* 판정은 매일 KST 자정 sFPI 누적 직후(`fnFpiDailySnapshot`)에 돈다. 실기기 확인 때 하루를
* 기다리지 않도록 같은 판정을 즉시 부르는 경로다. 조건을 우회하지 않으며 결과(승급 없음
* 포함)를 `adminActionLogs` 에 남긴다.
*/
export const fnAdminPlanetEvolutionRun = onCall(async (req) => {
const actorUid = callerUid(req);
const role = requireRole(req, ['master']);
const fandomId = str(req.data, 'fandomId');
if (fandomId.includes('/')) fail(ERR.permissionDenied, '문서 경로가 아닙니다.');
const [promotion] = await promoteStages([fandomId]);
await getFirestore().collection('adminActionLogs').add({
actorUid, role, targetPath: `fandoms/${fandomId}`, reason: 'PLANET_EVOLUTION_RUN',
patch: promotion ? { fromStage: promotion.fromStage, stage: promotion.toStage } : { promoted: false },
createdAt: FieldValue.serverTimestamp(),
});
return { fandomId, promoted: promotion ?? null };
});
+5 -2
View File
@@ -13,6 +13,7 @@ import { rankWithTies } from '../ledger/fpi.js';
import { txIdOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { notifyEclipseWarning } from '../notify/push.js';
import { promoteStages } from './evolution.js';
import { PUBLIC_NAME_FALLBACK, publicNameOf } from '../core/names.js';
/**
@@ -842,7 +843,7 @@ export const fnFpiDailySnapshot = onSchedule(
* 인정해 남긴 FPI 원천 이벤트(`fpiEvents`, verified · uid 있음) 1건 이상** — S01 확정 전
* 유지 기준. 활동 0명인 날도 `dau: 0` 으로 남긴다(C-27 「0명인 날 포함」).
*/
export async function closeDay(now: Date = new Date()): Promise<{ day: string; fandoms: number }> {
export async function closeDay(now: Date = new Date()): Promise<{ day: string; fandoms: number; promoted: number }> {
const db = getFirestore();
const day = dayKey(new Date(now.getTime() - 24 * 60 * 60 * 1000));
const from = Timestamp.fromDate(new Date(`${day}T00:00:00+09:00`));
@@ -881,7 +882,9 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
tx.set(fandom.ref, { sfpi: FieldValue.increment(fpiScore) }, { merge: true });
});
}
return { day, fandoms: fandoms.size };
// 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다.
const promoted = await promoteStages();
return { day, fandoms: fandoms.size, promoted: promoted.length };
}
/**
+2 -1
View File
@@ -13,6 +13,7 @@ export {
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnAdminSetRole,
} from './account/account.js';
export { fnAuthKakao } from './account/kakao.js';
export {
fnEclipseDefend, fnEclipseAggregate, fnEclipseTrigger,
fnFpiAggregate, fnLiveAggregate, fnFpiDailySnapshot,
@@ -49,5 +50,5 @@ export {
} from './account/translation.js';
export { fnBriefingIngest, fnBriefingPublish } from './briefing/briefing.js';
export { fnPlanetEvolutionRecord } from './fandom/evolution.js';
export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js';
export { fnPlanetHistory } from './fandom/history.js';
@@ -0,0 +1,106 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getAuth } from 'firebase-admin/auth';
import { bootAdmin, shutdownAdmin } from './support.js';
import { fnAuthKakao, KAKAO_PROVIDER_ID, kakaoUidOf } from '../src/account/kakao.js';
/** 카카오 커스텀 토큰 — 토큰 검증 · 기존 oidc.kakao 사용자 매핑 · 신규 UID · SNS 연동. */
const APP_ID = '123456';
const KAKAO_ID = '987654321';
const OLD_UID = 'test-kakao-oidc-user';
const GOOGLE_UID = 'test-kakao-google-user';
type Req = Parameters<typeof fnAuthKakao.run>[0];
const call = (data: Record<string, unknown>, uid?: string) =>
fnAuthKakao.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req);
const codeOf = async (p: Promise<unknown>) => {
try { await p; return 'ok'; } catch (e) {
const d = (e as { details?: { code?: string; reason?: string } }).details;
return d?.reason ?? d?.code;
}
};
/** 카카오 API 흉내. 토큰 'good' 만 우리 앱 것, 'other-app' 은 남의 앱 토큰이다. */
function stubKakao(email?: string) {
vi.stubGlobal('fetch', vi.fn(async (url: string, init: { headers: Record<string, string> }) => {
const token = init.headers.Authorization?.replace('Bearer ', '');
if (token === 'expired') return new Response('{}', { status: 401 });
if (url.endsWith('/v1/user/access_token_info')) {
return Response.json({ id: Number(KAKAO_ID), expires_in: 100, app_id: token === 'other-app' ? 1 : Number(APP_ID) });
}
return Response.json({
id: Number(KAKAO_ID),
kakao_account: {
profile: { nickname: '카카오팬' },
...(email ? { email, is_email_valid: true, is_email_verified: true } : {}),
},
});
}));
}
async function wipe() {
const uids = [OLD_UID, GOOGLE_UID, kakaoUidOf(KAKAO_ID)];
await getAuth().deleteUsers(uids);
}
beforeAll(() => {
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) {
throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.');
}
process.env.KAKAO_APP_ID = APP_ID;
bootAdmin();
});
afterAll(async () => { await wipe(); await shutdownAdmin(); });
beforeEach(async () => { await wipe(); stubKakao(); });
afterEach(() => { vi.unstubAllGlobals(); });
describe('토큰 검증', () => {
it('만료·무효 토큰과 남의 앱 토큰은 E-AUTH-03', async () => {
expect(await codeOf(call({ accessToken: 'expired' }))).toBe('E-AUTH-03');
expect(await codeOf(call({ accessToken: 'other-app' }))).toBe('E-AUTH-03');
});
});
describe('로그인', () => {
it('OIDC 시절 oidc.kakao 가입자는 같은 UID 로 들어온다', async () => {
await getAuth().createUser({ uid: OLD_UID });
await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: KAKAO_ID } });
const { token } = await call({ accessToken: 'good' }) as { token: string };
expect(JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid).toBe(OLD_UID);
});
it('신규는 kakao:<회원번호> 로 만들고 oidc.kakao 를 붙인다 · 두 번째도 같은 UID', async () => {
const first = await call({ accessToken: 'good' }) as { token: string };
await call({ accessToken: 'good' });
const uid = JSON.parse(Buffer.from(first.token.split('.')[1]!, 'base64url').toString()).uid;
expect(uid).toBe(kakaoUidOf(KAKAO_ID));
const user = await getAuth().getUser(uid);
expect(user.displayName).toBe('카카오팬');
expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[KAKAO_PROVIDER_ID, KAKAO_ID]]);
});
});
describe('SNS 연동', () => {
it('로그인 없이 link 는 E-AUTH-03', async () => {
expect(await codeOf(call({ accessToken: 'good', link: true }))).toBe('E-AUTH-03');
});
it('Google 사용자에 붙인다 · 다른 UID 가 쓰는 카카오는 E-AUTH-08', async () => {
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
const user = await getAuth().getUser(GOOGLE_UID);
expect(user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID && d.uid === KAKAO_ID)).toBe(true);
await getAuth().createUser({ uid: OLD_UID });
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
});
it('이메일이 다르면 붙이지 않는다(different-email) · 카카오 이메일이 없으면 비교를 건너뛴다', async () => {
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
stubKakao('other@kakao.com');
expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('different-email');
stubKakao('FAN@fanuzu.co.kr');
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
});
});
@@ -0,0 +1,83 @@
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
const push = vi.hoisted(() => vi.fn(async () => true));
vi.mock('../src/notify/push.js', () => ({ sendFandomPush: push, notifyEclipseWarning: vi.fn() }));
const { fnAdminPlanetEvolutionRun, promoteStages } = await import('../src/fandom/evolution.js');
const { closeDay } = await import('../src/fandom/fandom.js');
/** 설계서 §2.3 누계 — 발주사 수치표 v1.4 「Stage Summary」(seed-demo.py 와 같은 표). */
const STAGES: Array<[number, number, number]> = [
[1, 0, 0], [2, 100_000, 15_000], [3, 500_000, 75_000], [4, 2_000_000, 300_000],
[5, 10_000_000, 1_500_000], [6, 50_000_000, 7_500_000],
];
const id = 'test-stage-promotion';
const db = () => getFirestore();
const stage = async () => (await db().doc(`fandoms/${id}`).get()).get('stage');
const set = (data: Record<string, unknown>) => db().doc(`fandoms/${id}`).set({ name: '승급', ...data });
beforeAll(async () => {
bootAdmin();
for (const [n, pop, sfpi] of STAGES) {
await db().doc(`planetStages/${n}`).set({ stageNo: n, requiredPop: pop, requiredSfpi: sfpi });
}
});
beforeEach(async () => {
push.mockClear();
await db().recursiveDelete(db().doc(`fandoms/${id}`));
});
afterAll(async () => {
await db().recursiveDelete(db().doc(`fandoms/${id}`));
await shutdownAdmin();
});
it('조건 미달 · 한쪽만 충족이면 대기한다', async () => {
for (const [pop, sfpi] of [[99_999, 14_999], [5_000_000, 14_999], [99_999, 9_000_000]]) {
await set({ stage: 1, growthPop: pop, sfpi });
expect(await promoteStages([id])).toEqual([]);
expect(await stage()).toBe(1);
}
expect(push).not.toHaveBeenCalled();
});
it('둘 다 충족하면 오르고, 두 번 돌아도 한 번만 오른다', async () => {
await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 });
await Promise.all([promoteStages([id]), promoteStages([id])]);
await promoteStages([id]);
expect(await stage()).toBe(2);
expect(push).toHaveBeenCalledTimes(1);
});
it('여러 Stage 를 채웠으면 동시 충족한 가장 높은 Stage 까지 · 성운 · 최종 단계는 건드리지 않는다', async () => {
// POP 은 Stage5 까지, sFPI 는 Stage3 까지 → Stage3.
await set({ stage: 1, growthPop: 10_000_000, sfpi: 100_000 });
expect(await promoteStages([id])).toEqual([{ fandomId: id, fromStage: 1, toStage: 3 }]);
await set({ stage: 6, growthPop: 1e12, sfpi: 1e12 });
expect(await promoteStages([id])).toEqual([]);
await set({ stage: 0, growthPop: 1e12, sfpi: 1e12 });
expect(await promoteStages([id])).toEqual([]);
expect(await stage()).toBe(0);
});
it('일일 마감(sFPI 누적) 직후 판정한다 — 그날 누적으로 문턱을 넘으면 오른다', async () => {
await set({ stage: 1, growthPop: 100_000, sfpi: 14_000, fpiScore: 1_000 });
await closeDay(new Date('2030-01-02T00:00:30+09:00'));
expect(await stage()).toBe(2);
await closeDay(new Date('2030-01-02T00:00:30+09:00'));
expect((await db().doc(`fandoms/${id}`).get()).get('sfpi')).toBe(15_000);
expect(push).toHaveBeenCalledTimes(1);
});
it('운영자 즉시 실행은 master 만 · 결과를 adminActionLogs 에 남긴다', async () => {
await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 });
const run = (role: string) => fnAdminPlanetEvolutionRun.run({
data: { fandomId: id }, auth: { uid: `evo-${role}`, token: { role } },
} as unknown as Parameters<typeof fnAdminPlanetEvolutionRun.run>[0]);
await expect(run('ops')).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
expect(await stage()).toBe(1);
expect((await run('master')).promoted).toEqual({ fandomId: id, fromStage: 1, toStage: 2 });
const logs = await db().collection('adminActionLogs').where('actorUid', '==', 'evo-master').get();
expect(logs.docs.some((d) => d.get('reason') === 'PLANET_EVOLUTION_RUN' && d.get('patch.stage') === 2)).toBe(true);
});
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"test": "npm run test:rules && npm run test:functions",
"test:rules": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix test test\"",
"test:functions": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix functions test\""
"test:functions": "firebase emulators:exec --only firestore,auth --project demo-fanuzu \"npm --prefix functions test\""
},
"devDependencies": {
"firebase-tools": "15.28.2"