191 lines
9.9 KiB
TypeScript
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);
|
|
});
|
|
});
|