가입 약관 동의 기록 · 서비스 탈퇴 · 온보딩 6단계 · Coach Mark
This commit is contained in:
@@ -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` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
|
||||
*
|
||||
|
||||
@@ -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) };
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user