가입 약관 동의 기록 · 서비스 탈퇴 · 온보딩 6단계 · Coach Mark

This commit is contained in:
Sn-Kinos
2026-10-05 22:48:25 +09:00
parent e7010010bb
commit 55424bd4a2
36 changed files with 2884 additions and 77 deletions
+108 -9
View File
@@ -19,13 +19,19 @@ import { ERR, fail } from '../core/errors.js';
*/
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
const db = getFirestore();
const batch = db.batch();
batch.set(db.doc(`users/${user.uid}`), {
const userRef = db.doc(`users/${user.uid}`);
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
// 이메일 — 10/5 「플랫폼이 이메일 제공을 승인·동의한 경우에만 수집, 없어도 가입·로그인 가능」.
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
// 계정을 만든 뒤에 연결 정보가 붙으므로 `resolveKakaoUid` 가 따로 쓴다.
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
const defaults: Record<string, unknown> = {
uid: user.uid,
displayName: user.displayName ?? '팬우주 여행자',
photoUrl: user.photoURL ?? null,
countryCode: 'KR',
// 국가는 정하지 않는다 — 온보딩 프로필 단계가 기기 지역을 기본값으로 받아 쓴다
// (해석 확인 Q-OB-02 A 「프로필 = 닉네임 · 국가 · 표시 언어」). 비어 있으면 국가 랭킹은 `ZZ`.
email,
locale: 'ko',
fandomId: '',
authProviders: user.providerData.map((p) => p.providerId),
@@ -35,18 +41,25 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
notificationPrefs: {},
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
batch.set(db.doc(`users/${user.uid}/wallet/main`), {
};
const wallet: Record<string, unknown> = {
activityBalance: 0,
supportBalance: 0,
pendingActivity: 0,
reconcileStatus: 'ok',
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
};
await batch.commit();
// 트리거가 늦게 돌면 그 사이 앱이 쓴 값(약관 동의 · 닉네임 · 국가 · 이메일)이 이미 있다.
// 없는 필드만 채운다 — 덮어쓰면 온보딩 입력이 기본값으로 되돌아간다.
await db.runTransaction(async (tx) => {
const [u, w] = await Promise.all([tx.get(userRef), tx.get(walletRef)]);
const missing = (base: Record<string, unknown>, have: Record<string, unknown> | undefined) =>
Object.fromEntries(Object.entries(base).filter(([k]) => have?.[k] == null));
tx.set(userRef, missing(defaults, u.data()), { merge: true });
tx.set(walletRef, missing(wallet, w.data()), { merge: true });
});
});
/** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */
@@ -434,6 +447,92 @@ export const fnConsentRecord = onCall(async (req) => {
return { consentId };
});
/**
* 가입 약관 필수 항목 — Figma `179:24230`(서비스 이용약관 · 위치기반 서비스 · 만 14세 이상)에
* 10/3 통합회신 3절 4 · 10/5 「개인정보처리방침을 약관과 함께 전달」의 개인정보 처리방침을 더했다.
* 항목 구성은 해석 확인 요청 E-Q1(회신 대기). 마케팅 수신은 선택 · 기본 OFF(별첨 B 「Push 알림」).
*/
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'location', 'age'] as const;
/**
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 원장 C-134).
*
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
* 찍는다. 앱은 이 값이 없으면 로그인 직후 온보딩(약관부터)으로 보낸다 — 「로그인」 버튼으로 처음
* 들어온 소셜 계정(카카오 커스텀 토큰 포함)이 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
* 마케팅 동의는 Push Marketing 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다.
*/
export const fnSignupConsent = onCall(async (req) => {
const uid = callerUid(req);
const version = str(req.data, 'version');
const raw = (req.data as Record<string, unknown>)?.agreed;
const agreed = new Set(Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : []);
if (!SIGNUP_REQUIRED_TERMS.every((t) => agreed.has(t))) {
throw new HttpsError('invalid-argument', '필수 약관에 모두 동의해 주세요.');
}
const marketing = agreed.has('marketing');
const db = getFirestore();
const batch = db.batch();
const meta = {
docVersion: version,
ip: req.rawRequest?.ip ?? null,
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
createdAt: FieldValue.serverTimestamp(),
};
for (const docType of [...SIGNUP_REQUIRED_TERMS, 'marketing']) {
const granted = docType === 'marketing' ? marketing : true;
batch.set(db.doc(`users/${uid}/consents/${docType}_${version}_${granted ? 'grant' : 'revoke'}`),
{ docType, granted, ...meta }, { merge: true });
}
batch.set(db.doc(`users/${uid}`), {
termsAgreedAt: FieldValue.serverTimestamp(),
termsVersion: version,
notificationPrefs: { marketing },
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
await batch.commit();
return { termsVersion: version, marketing };
});
/**
* `fn-account-withdraw` — 서비스 탈퇴. Figma `554:22903` · `554:24885` · `554:24908`.
*
* - SNS 연동이 남아 있으면(로그인 수단 2개 이상) 「비정상 탈퇴」로 거부한다 — 「연동된 계정 해제 후
* 계정 탈퇴를 진행해주세요.」(`374:14699`).
* - 원장성 데이터(POP 원장 · 결제 · 정산 · 기여)와 작성한 게시물은 지우지 않는다(Figma 「게시물은 삭제되지
* 않고 데이터 보존」 · 별첨 B 「원장 보존 5년」). 사용자 문서의 개인식별정보(닉네임 · 표시 이름 · 이메일 ·
* 사진 · 푸시 토큰)는 지우고 `status: 'withdrawn'` · `withdrawnAt` 을 남긴다(별첨 B 「탈퇴 후 개인식별정보는
* 파기/비식별」, Admin 사용자 일람의 탈퇴일 · 상태 `557:40942`).
* - Auth 계정을 지운다. 같은 소셜 계정으로 다시 가입하면 새 계정이다.
*/
export const fnAccountWithdraw = onCall(async (req) => {
const uid = callerUid(req);
const auth = getAuth();
const user = await auth.getUser(uid);
if (user.providerData.length > 1) {
fail(ERR.withdrawLinked, '연동된 계정 해제 후 계정 탈퇴를 진행해주세요.');
}
const db = getFirestore();
const userRef = db.doc(`users/${uid}`);
const code = (await userRef.get()).get('inviteCode');
const batch = db.batch();
batch.set(userRef, {
status: 'withdrawn',
withdrawnAt: FieldValue.serverTimestamp(),
nickname: null,
displayName: null,
email: null,
photoUrl: null,
fcmTokens: FieldValue.delete(),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
// 탈퇴한 사람의 추천 코드로 새 귀속이 생기지 않게 닫는다.
if (typeof code === 'string' && code) batch.set(db.doc(`inviteCodes/${code}`), { active: false }, { merge: true });
await batch.commit();
await auth.deleteUser(uid);
return { withdrawn: true };
});
/**
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
*
+11 -1
View File
@@ -1,4 +1,5 @@
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { defineString } from 'firebase-functions/params';
import { onCall } from 'firebase-functions/v2/https';
@@ -96,7 +97,11 @@ function linkInfo(p: KakaoProfile) {
export async function resolveKakaoUid(p: KakaoProfile): Promise<string> {
const existing = await ownerOf(p.id);
if (existing) return existing;
const uid = kakaoUidOf(p.id);
// 탈퇴한 계정의 문서(원장 보존)가 `kakao:<id>` 에 남아 있으면 같은 UID 로 다시 만들지 않는다 —
// 다시 가입한 사람이 탈퇴 전 지갑 · 원장을 물려받는다.
const base = kakaoUidOf(p.id);
const withdrawn = (await getFirestore().doc(`users/${base}`).get()).get('status') === 'withdrawn';
const uid = withdrawn ? `${base}:${Date.now()}` : base;
const auth = getAuth();
try {
await auth.createUser({ uid, ...(p.nickname ? { displayName: p.nickname } : {}) });
@@ -139,5 +144,10 @@ export const fnAuthKakao = onCall(async (req) => {
return { linked: true };
}
const uid = await resolveKakaoUid(profile);
// 동의한 이메일만 사용자 문서에 남긴다(10/5). Auth 이메일은 다른 제공자 계정과 겹치면 생성이
// 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다.
if (profile.email) {
await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true });
}
return { token: await getAuth().createCustomToken(uid) };
});
+7 -11
View File
@@ -9,7 +9,6 @@ import { callerUid, optionalStr, str } from '../core/callable.js';
import { loadPolicy } from '../core/config.js';
import { ERR, fail } from '../core/errors.js';
import { deviceHashOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { grantPop, settlePendingGrant, spendPop } from '../ledger/ledger.js';
import { fandomOfUser, writeFpiEvent } from '../ledger/fpi.js';
@@ -75,7 +74,7 @@ export const REFERRAL_HOLD_MS = 24 * 60 * 60 * 1000;
* 잠금 문서 `referralsByUser/{uid}.reward` — 피추천인 1명의 보상 상태.
* - `pending`: 귀속됨 · 귀속 뒤 첫 검증 활동 대기
* - `ineligible`: 귀속 전에 이미 검증 활동이 있었다(「최초」가 아님) — 지급 없음
* - `capped`: 추천인의 하루 상한(`referralDailyCapPerReferrer`) 초과 — 지급 없음
* - `capped`: (폐지) 추천인 하루 상한 초과 — 10/3 통합회신 7항으로 상한 삭제. 이전 기록 해석용으로만 남긴다
* - `held`: 보류 지급(pending POP) 중 · `settleAfter` 뒤 확정
* - `paid`: 확정 · FPI REFERRAL 1 단위
* - `released`: 확정 전에 완료가 무효 · 이상 표시(`flagged`)로 보류 해제
@@ -110,18 +109,19 @@ export const fnReferralAttribute = onCall(async (req) => {
// 자기 추천. 계정 하나로 POP 을 만들어 내는 가장 쉬운 길이다.
fail(ERR.questAbnormal, '본인 추천 코드는 쓸 수 없습니다.');
}
const { referralAttachDays, referralDailyCapPerReferrer } = await loadPolicy();
// 추천인 1명당 하루 보상 상한은 두지 않는다 — 10/3 통합회신 7항 「삭제」(원장 C-120).
// 자기 · 상호 · 중복 · 이상 패턴은 아래 판정과 Risk Engine 이 맡는다.
const { referralAttachDays } = await loadPolicy();
const attributionRef = db.doc(`referrals/${uid}_${code}`);
const lockRef = db.doc(`referralsByUser/${uid}`);
const capRef = db.doc(`referralDaily/${referrerUid}_${dayKey(new Date())}`);
let reward: ReferralReward = 'pending';
try {
await db.runTransaction(async (tx) => {
// 동시 호출의 둘째는 잠금 문서 `create` 충돌(ALREADY_EXISTS)로 떨어진다.
const [existing, lock, user, reverse, cap, done] = await Promise.all([
const [existing, lock, user, reverse, done] = await Promise.all([
tx.get(attributionRef), tx.get(lockRef), tx.get(db.doc(`users/${uid}`)),
tx.get(db.doc(`referralsByUser/${referrerUid}`)), tx.get(capRef),
tx.get(db.doc(`referralsByUser/${referrerUid}`)),
// 「최초 검증 활동」은 귀속 뒤의 첫 완료다. 같은 트랜잭션에서 읽어 경합에도 판정이 같다.
tx.get(db.collection(`users/${uid}/verifiedCompletions`).limit(1)),
]);
@@ -132,11 +132,7 @@ export const fnReferralAttribute = onCall(async (req) => {
Date.now() - joined.toMillis() > referralAttachDays * 86_400_000) {
fail(ERR.popVerifyFailed, `가입 후 ${referralAttachDays}일 안에만 추천 코드를 등록할 수 있습니다.`);
}
const today = (cap.get('count') as number | undefined) ?? 0;
reward = !done.empty ? 'ineligible' : today >= referralDailyCapPerReferrer ? 'capped' : 'pending';
if (reward === 'pending') {
tx.set(capRef, { count: today + 1, updatedAt: FieldValue.serverTimestamp() }, { merge: true });
}
reward = done.empty ? 'pending' : 'ineligible';
tx.create(lockRef, {
attributionId: attributionRef.id, referrerUid, reward, createdAt: FieldValue.serverTimestamp(),
});
@@ -114,8 +114,6 @@ export interface PolicyConfig {
/** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
referralReferrerPop: number;
referralReferredPop: number;
/** 추천인 1명이 하루에 보상 대상으로 받을 수 있는 귀속 수(B-71 어뷰징). 넘으면 `capped`. 🟡 운영값 5. */
referralDailyCapPerReferrer: number;
/** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */
referralAttachDays: number;
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
@@ -223,7 +221,6 @@ export const DEFAULT_POLICY: PolicyConfig = {
liveWindowMinutes: 5,
referralReferrerPop: 100,
referralReferredPop: 20,
referralDailyCapPerReferrer: 5,
referralAttachDays: 7,
timeAttackMaxMinutes: 1440,
// 🟡 임시값 — 회신 대기 · 근거 없음(위 MusicShakePolicy 설명).
@@ -40,6 +40,8 @@ export const ERR = {
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다.
authCredentialInUse: 'E-AUTH-08',
authAlreadyLinked: 'E-AUTH-09',
// 서비스 탈퇴 — SNS 연동이 남아 있으면 「비정상 탈퇴」(Figma `554:24885` · `554:24908`).
withdrawLinked: 'E-AUTH-14',
// 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다.
trnSelfMark: 'E-TRN-01',
trnDailyCap: 'E-TRN-02',
@@ -84,6 +86,7 @@ const STATUS: Record<ErrorCode, Status> = {
'E-AUTH-03': 'permission-denied',
'E-AUTH-08': 'already-exists',
'E-AUTH-09': 'already-exists',
'E-AUTH-14': 'failed-precondition',
'E-TRN-01': 'permission-denied',
'E-TRN-02': 'resource-exhausted',
'E-TRN-03': 'not-found',
+1 -1
View File
@@ -11,7 +11,7 @@ export {
} from './quest/quest.js';
export {
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnAdminSetRole,
fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnSignupConsent, fnAccountWithdraw, fnAdminSetRole,
} from './account/account.js';
export { fnAuthKakao } from './account/kakao.js';
export {
@@ -0,0 +1,127 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { fnAccountWithdraw, fnAuthOnCreate, fnSignupConsent } from '../src/account/account.js';
import { fnAuthKakao, KAKAO_PROVIDER_ID, kakaoUidOf } from '../src/account/kakao.js';
/** 가입 약관 기록(E-04 · E-05) · 계정 생성 트리거의 덮어쓰기 금지 · 국가 미고정(E-13) · 서비스 탈퇴(E-42) · 카카오 이메일(E-02). */
const U = 'test-onb-user';
const KAKAO_ID = '555000111';
const db = () => getFirestore();
type CallReq = Parameters<typeof fnSignupConsent.run>[0];
const as = (uid: string, data: Record<string, unknown>) => ({ data, auth: { uid, token: {} } }) as CallReq;
const codeOf = async (p: Promise<unknown>) => {
try { await p; return 'ok'; } catch (e) {
const err = e as { code?: string; details?: { code?: string } };
return err.details?.code ?? err.code;
}
};
const onCreate = (uid: string, extra: Record<string, unknown> = {}) =>
(fnAuthOnCreate as unknown as { run: (u: unknown) => Promise<void> })
.run({ uid, email: undefined, displayName: undefined, photoURL: undefined, providerData: [], ...extra });
function stubKakao(email?: string) {
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url.endsWith('/v1/user/access_token_info')) return Response.json({ id: Number(KAKAO_ID), app_id: 123 });
return Response.json({
id: Number(KAKAO_ID),
kakao_account: { profile: { nickname: '카카오' }, ...(email ? { email, is_email_valid: true, is_email_verified: true } : {}) },
});
}));
}
async function wipe() {
await getAuth().deleteUsers([U, kakaoUidOf(KAKAO_ID)]);
for (const uid of [U, kakaoUidOf(KAKAO_ID)]) {
await db().recursiveDelete(db().doc(`users/${uid}`));
await wipeUser(uid);
}
}
beforeAll(() => {
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다.');
process.env.KAKAO_APP_ID = '123';
bootAdmin();
});
afterAll(async () => { await wipe(); await shutdownAdmin(); });
beforeEach(wipe);
afterEach(() => { vi.unstubAllGlobals(); });
describe('계정 생성 트리거', () => {
it('국가를 KR 로 고정하지 않고 · 이미 쓴 값(약관 · 닉네임)을 덮지 않는다', async () => {
await db().doc(`users/${U}`).set({ nickname: '먼저씀', termsVersion: 'v1' });
await onCreate(U, { providerData: [{ providerId: 'oidc.line', email: 'line@fan.io' }] });
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.countryCode).toBeUndefined();
expect(user.nickname).toBe('먼저씀');
expect(user.termsVersion).toBe('v1');
expect(user.email).toBe('line@fan.io');
expect(user.status).toBe('active');
expect((await db().doc(`users/${U}/wallet/main`).get()).get('activityBalance')).toBe(0);
});
});
describe('fnSignupConsent', () => {
it('필수 하나라도 빠지면 거부', async () => {
const r = fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'location', 'age'] }));
expect(await codeOf(r)).toBe('invalid-argument');
expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined();
});
it('항목별 행 · termsAgreedAt · 마케팅 = Push Marketing', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'location', 'age'] }));
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.termsAgreedAt).toBeDefined();
expect(user.notificationPrefs.marketing).toBe(false);
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.id).sort();
expect(rows).toEqual(['age_v1_grant', 'location_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
});
});
describe('fnAccountWithdraw', () => {
it('SNS 연동이 남아 있으면 E-AUTH-14 (비정상 탈퇴)', async () => {
await getAuth().createUser({ uid: U });
await getAuth().updateUser(U, { providerToLink: { providerId: 'google.com', uid: 'g1' } });
await getAuth().updateUser(U, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: 'k1' } });
expect(await codeOf(fnAccountWithdraw.run(as(U, {})))).toBe('E-AUTH-14');
expect((await getAuth().getUser(U)).uid).toBe(U);
});
it('개인식별정보를 지우고 원장은 남기며 Auth 계정을 지운다', async () => {
await getAuth().createUser({ uid: U });
await db().doc(`users/${U}`).set({ nickname: '팬', email: 'a@b.c', status: 'active' });
await db().doc(`users/${U}/popTransactions/t1`).set({ amount: 10 });
expect(await fnAccountWithdraw.run(as(U, {}))).toEqual({ withdrawn: true });
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.status).toBe('withdrawn');
expect(user.nickname).toBeNull();
expect(user.email).toBeNull();
expect((await db().doc(`users/${U}/popTransactions/t1`).get()).exists).toBe(true);
await expect(getAuth().getUser(U)).rejects.toBeDefined();
});
});
describe('카카오 이메일 · 탈퇴 후 재가입', () => {
it('동의한 이메일만 사용자 문서에 남기고 · 없어도 로그인된다', async () => {
stubKakao();
await fnAuthKakao.run({ data: { accessToken: 'good' } } as Parameters<typeof fnAuthKakao.run>[0]);
expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBeUndefined();
stubKakao('fan@kakao.com');
await fnAuthKakao.run({ data: { accessToken: 'good' } } as Parameters<typeof fnAuthKakao.run>[0]);
expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBe('fan@kakao.com');
});
it('탈퇴한 kakao:<id> 문서가 있으면 새 UID 로 만든다', async () => {
stubKakao();
await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).set({ status: 'withdrawn' });
const { token } = await fnAuthKakao.run({ data: { accessToken: 'good' } } as Parameters<typeof fnAuthKakao.run>[0]) as { token: string };
const uid = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid as string;
expect(uid).not.toBe(kakaoUidOf(KAKAO_ID));
expect(uid.startsWith(`${kakaoUidOf(KAKAO_ID)}:`)).toBe(true);
await getAuth().deleteUser(uid);
});
});
+2 -4
View File
@@ -93,13 +93,11 @@ describe('귀속 제한', () => {
expect((await db().collection('referrals').where('referredUid', '==', C).get()).size).toBe(1);
});
it('추천인 하루 상한(referralDailyCapPerReferrer)을 넘으면 귀속만 남고 capped', async () => {
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('capped');
await complete(C);
expect((await wallet(A)).pendingActivity ?? 0).toBe(0);
expect((await attribute(C, codeA)).reward).toBe('pending');
});
it('귀속 전에 검증 활동이 있으면 ineligible — 지급 없음', async () => {