Files

191 lines
9.9 KiB
TypeScript

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<typeof fnReferralAttribute.run>[0];
const attribute = (uid: string, referralCode: string) =>
fnReferralAttribute.run({ data: { referralCode, deviceRaw: 'device-1' }, auth: { uid, token: {} } } as AttrReq);
const codeOf = async (p: Promise<unknown>) => {
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<typeof fnInviteCodeGet.run>[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('추천인 하루 상한이 없다 — 같은 날 여러 명을 추천해도 모두 pending (10/3 통합회신 7항)', 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('pending');
});
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);
});
});