import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; import { fnInviteCodeGet, fnReferralAttribute, fnReferralRewardOnCompletion, fnReferralSettle, inviteCodeOf, onReferralCompletionVoided, payReferralReward, settleReferral, } from '../src/account/referral.js'; /** 추천 — 초대 코드 발급 · 귀속 · 보류 지급 · 24h 정산 · 무효 시 해제/회수(B-69 · B-71). */ const A = 'test-ref-a'; const B = 'test-ref-b'; const C = 'test-ref-c'; const USERS = [A, B, C]; const FANDOM = 'test-ref-fandom'; const db = () => getFirestore(); type AttrReq = Parameters[0]; const attribute = (uid: string, referralCode: string) => fnReferralAttribute.run({ data: { referralCode, deviceRaw: 'device-1' }, auth: { uid, token: {} } } as AttrReq); const codeOf = async (p: Promise) => { try { await p; return 'ok'; } catch (e) { return (e as { details?: { code?: string } }).details?.code; } }; const wallet = async (uid: string) => (await db().doc(`users/${uid}/wallet/main`).get()).data() ?? {}; const lock = async (uid: string) => (await db().doc(`referralsByUser/${uid}`).get()).data() ?? {}; const complete = async (uid: string, id = 'q_2026-09-28') => { await db().doc(`users/${uid}/verifiedCompletions/${id}`).set({ questId: 'q' }); await fnReferralRewardOnCompletion.run({ params: { uid, completionId: id } } as never); }; const fpiEvents = async () => (await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).docs; async function cleanup() { const owned = await db().collection('inviteCodeOwners').where('uid', 'in', USERS).get(); for (const d of owned.docs) await Promise.all([d.ref.delete(), db().doc(`inviteCodes/${d.id}`).delete()]); for (const u of USERS) { await db().doc(`referralsByUser/${u}`).delete(); const rows = await db().collection('referrals').where('referredUid', '==', u).get(); await Promise.all(rows.docs.map((d) => d.ref.delete())); } const daily = await db().collection('referralDaily').get(); await Promise.all(daily.docs.filter((d) => USERS.some((u) => d.id.startsWith(`${u}_`))).map((d) => d.ref.delete())); await db().recursiveDelete(db().doc(`fandoms/${FANDOM}`)); await db().doc('config/policy').delete(); for (const u of USERS) await wipeUser(u); for (const u of USERS) { await db().doc(`users/${u}`).set({ uid: u, fandomId: FANDOM, createdAt: Timestamp.now() }); } } beforeAll(() => { bootAdmin(); }); afterAll(async () => { await cleanup(); await shutdownAdmin(); }); beforeEach(cleanup); describe('초대 코드 발급', () => { it('사용자당 1개 · 10자 32진 · 공개 문서는 {active} 만, 주인은 서버 전용 문서', async () => { const [x, y] = await Promise.all([inviteCodeOf(A), inviteCodeOf(A)]); const again = (await fnInviteCodeGet.run({ data: {}, auth: { uid: A, token: {} } } as Parameters[0])).code; expect(new Set([x, y, again]).size).toBe(1); expect(x).toMatch(/^[A-HJ-NP-Z2-9]{10}$/); expect((await db().doc(`inviteCodes/${x}`).get()).data()).toEqual({ active: true }); expect((await db().doc(`inviteCodeOwners/${x}`).get()).get('uid')).toBe(A); expect(await inviteCodeOf(B)).not.toBe(x); }); it('형식이 틀린 코드는 조회 전에 거부한다', async () => { for (const bad of ['short', 'ABCDEFGHI1', 'abcdefgh23', 'ABCDEFGH23/x']) { expect(await codeOf(attribute(B, bad))).toBe('E-POP-02'); } // 형식은 맞지만 없는 코드. expect(await codeOf(attribute(B, 'ZZZZZZZZZ2'))).toBe('E-POP-02'); }); }); describe('귀속 제한', () => { it('자기 추천 · 서로 추천 · 가입 7일 경과 · 가입자당 1회', async () => { const codeA = await inviteCodeOf(A); const codeB = await inviteCodeOf(B); expect(await codeOf(attribute(A, codeA))).toBe('E-QST-02'); expect((await attribute(B, codeA)).reward).toBe('pending'); expect((await db().doc(`referrals/${B}_${codeA}`).get()).get('deviceHash')).toMatch(/^[0-9a-f]{32}$/); expect(await codeOf(attribute(A, codeB))).toBe('E-QST-02'); // B→A 가 있으니 A→B 불가 expect(await codeOf(attribute(B, await inviteCodeOf(C)))).toBe('E-POP-03'); await db().doc(`users/${C}`).update({ createdAt: Timestamp.fromMillis(Date.now() - 8 * 86_400_000) }); expect(await codeOf(attribute(C, codeA))).toBe('E-POP-02'); }); it('서로 다른 코드 두 개를 동시에 보내도 귀속은 1건이다', async () => { const [ca, cb] = [await inviteCodeOf(A), await inviteCodeOf(B)]; const settled = await Promise.allSettled([attribute(C, ca), attribute(C, cb)]); expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1); expect((await db().collection('referrals').where('referredUid', '==', C).get()).size).toBe(1); }); it('추천인 하루 상한(referralDailyCapPerReferrer)을 넘으면 귀속만 남고 capped', async () => { await db().doc('config/policy').set({ referralDailyCapPerReferrer: 1 }); const codeA = await inviteCodeOf(A); expect((await attribute(B, codeA)).reward).toBe('pending'); expect((await attribute(C, codeA)).reward).toBe('capped'); await complete(C); expect((await wallet(A)).pendingActivity ?? 0).toBe(0); }); it('귀속 전에 검증 활동이 있으면 ineligible — 지급 없음', async () => { await db().doc(`users/${B}/verifiedCompletions/q_2026-09-27`).set({ questId: 'q' }); expect((await attribute(B, await inviteCodeOf(A))).reward).toBe('ineligible'); await complete(B, 'q_2026-09-28'); expect((await wallet(A)).pendingActivity ?? 0).toBe(0); }); it('경합 — 잠금보다 먼저 생긴 완료로는 지급하지 않는다', async () => { await db().doc(`users/${B}/verifiedCompletions/q_early`).set({ questId: 'q' }); // 귀속 트랜잭션이 완료를 못 본 채 잠금을 쓴 상황을 그대로 만든다. await db().doc(`referrals/${B}_RACE`).set({ referredUid: B, referrerUid: A, reward: 'pending' }); await db().doc(`referralsByUser/${B}`).set({ attributionId: `${B}_RACE`, referrerUid: A, reward: 'pending' }); expect(await payReferralReward(B, 'q_early')).toBe(false); await db().doc(`users/${B}/verifiedCompletions/q_late`).set({ questId: 'q' }); expect(await payReferralReward(B, 'q_late')).toBe(true); await db().doc(`referrals/${B}_RACE`).delete(); }); }); describe('보류 지급 → 24h 정산 → 무효 처리', () => { it('첫 검증 활동은 보류 지급 100 · 20, 정산 뒤 확정 · FPI 1 단위, 재실행해도 한 번', async () => { await attribute(B, await inviteCodeOf(A)); expect(await wallet(A)).toEqual({}); await complete(B); await complete(B, 'q_2026-09-29'); expect((await wallet(A)).pendingActivity).toBe(100); expect((await wallet(B)).pendingActivity).toBe(20); expect((await wallet(A)).activityBalance).toBe(0); expect((await lock(B)).reward).toBe('held'); expect(await fpiEvents()).toHaveLength(0); // 보류 시간이 안 지났으면 정산 작업이 건드리지 않는다. const run = () => fnReferralSettle.run({ scheduleTime: new Date().toISOString() } as never); await run(); expect((await lock(B)).reward).toBe('held'); await db().doc(`referralsByUser/${B}`).update({ settleAfter: Timestamp.fromMillis(Date.now() - 1) }); await run(); await run(); expect((await lock(B)).reward).toBe('paid'); expect(await wallet(A)).toMatchObject({ activityBalance: 100, pendingActivity: 0 }); expect(await wallet(B)).toMatchObject({ activityBalance: 20, pendingActivity: 0 }); const events = await fpiEvents(); expect(events.map((e) => [e.get('axis'), e.get('dauEligible')])).toEqual([['referral', false]]); expect((await db().doc(`users/${A}`).get()).get('userExp')).toBeUndefined(); }); it('정산 전에 유일한 완료가 무효가 되면 두 보류 지급을 해제한다', async () => { await attribute(B, await inviteCodeOf(A)); await complete(B); await db().doc(`users/${B}/verifiedCompletions/q_2026-09-28`).update({ voided: true }); expect(await onReferralCompletionVoided(B)).toBe('released'); expect(await wallet(A)).toMatchObject({ activityBalance: 0, pendingActivity: 0 }); expect(await wallet(B)).toMatchObject({ activityBalance: 0, pendingActivity: 0 }); expect(await settleReferral(B)).toBeNull(); expect(await fpiEvents()).toHaveLength(0); }); it('다른 유효 완료가 남아 있으면 무효 하나로는 건드리지 않는다', async () => { await attribute(B, await inviteCodeOf(A)); await complete(B, 'q1'); await complete(B, 'q2'); await db().doc(`users/${B}/verifiedCompletions/q1`).update({ voided: true }); expect(await onReferralCompletionVoided(B)).toBeNull(); expect((await lock(B)).reward).toBe('held'); }); it('정산 뒤 무효면 ADMIN_CLAWBACK 으로 회수하고 FPI 이벤트를 집계에서 뺀다(멱등)', async () => { await attribute(B, await inviteCodeOf(A)); await complete(B); expect(await settleReferral(B)).toBe('paid'); await db().doc(`users/${B}/verifiedCompletions/q_2026-09-28`).update({ voided: true }); expect(await onReferralCompletionVoided(B)).toBe('clawedback'); expect(await onReferralCompletionVoided(B)).toBeNull(); expect((await wallet(A)).activityBalance).toBe(0); expect((await wallet(B)).activityBalance).toBe(0); const reasons = (await db().collection(`users/${A}/popTransactions`).get()).docs.map((d) => d.get('reasonCode')); expect(reasons.filter((r) => r === 'ADMIN_CLAWBACK')).toHaveLength(1); expect((await fpiEvents()).map((e) => e.get('verified'))).toEqual([false]); }); it('이상 표시(flagged)가 있으면 정산 때 해제한다', async () => { await attribute(B, await inviteCodeOf(A)); await complete(B); await db().doc(`referralsByUser/${B}`).update({ flagged: true }); expect(await settleReferral(B)).toBe('released'); expect((await wallet(A)).activityBalance).toBe(0); }); });