Files
fanuzu/app/firebase/functions/test/account_onboarding.test.ts
T

180 lines
9.4 KiB
TypeScript

import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
fnAccountWithdraw, fnAuthOnCreate, fnConsentRecord, fnSignupConsent, hasLocationConsent,
} 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 — Q-E1 · R02', () => {
it('필수(서비스 · 개인정보 수집·이용 · 만 14세) 하나라도 빠지면 거부', async () => {
const r = fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'age'] }));
expect(await codeOf(r)).toBe('invalid-argument');
expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined();
});
it('위치기반 약관 없이 가입된다 · 항목별 행 · 마케팅 기본 OFF = Push Marketing', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', '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', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
expect(await hasLocationConsent(U)).toBe(false);
});
it('예전 앱(0.2.0+10)이 가입 화면에서 받은 location 동의는 기록해 위치 Quest 를 막지 않는다', async () => {
await fnSignupConsent.run(as(U, { version: 'v2', agreed: ['service', 'privacy', 'location', 'age'] }));
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.id);
expect(rows).toContain('location_v2_grant');
expect(await hasLocationConsent(U)).toBe(true);
});
});
describe('fnConsentRecord — 위치기능 최초 사용 전 동의 · 철회', () => {
it('동의 → 철회 → 재동의가 각각 행으로 남고(덮어쓰지 않음) 현재 상태를 따른다', async () => {
await fnConsentRecord.run(as(U, { docType: 'location', docVersion: 'loc-1', granted: true }));
expect(await hasLocationConsent(U)).toBe(true);
await fnConsentRecord.run(as(U, { docType: 'location', docVersion: 'loc-1', granted: false }));
expect(await hasLocationConsent(U)).toBe(false);
await fnConsentRecord.run(as(U, { docType: 'location', docVersion: 'loc-1', granted: true }));
expect(await hasLocationConsent(U)).toBe(true);
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.data());
expect(rows.map((r) => r.granted).sort()).toEqual([false, true, true]);
expect(rows.every((r) => r.docType === 'location' && r.docVersion === 'loc-1' && r.createdAt)).toBe(true);
const state = (await db().doc(`users/${U}`).get()).get('consentState.location');
expect(state.granted).toBe(true);
expect(state.version).toBe('loc-1');
});
it('가입 필수 항목은 이 경로로 철회할 수 없다', async () => {
expect(await codeOf(fnConsentRecord.run(as(U, { docType: 'service', docVersion: 'v1', granted: false }))))
.toBe('invalid-argument');
});
it('마케팅 철회는 Push Marketing 구분과 같은 값이 된다', async () => {
await fnConsentRecord.run(as(U, { docType: 'marketing', docVersion: 'v1', granted: true }));
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(true);
await fnConsentRecord.run(as(U, { docType: 'marketing', docVersion: 'v1', granted: false }));
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(false);
});
});
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();
});
it.skipIf(!process.env.STORAGE_EMULATOR_HOST)('본인 전용 Storage 경로는 지우고 라운지 글 사진은 남긴다', async () => {
await getAuth().createUser({ uid: U });
const bucket = getStorage().bucket();
const paths = [`users/${U}/profile/photo`, `questProofs/${U}/q1/1.jpg`, `users/${U}/lounge/aaaaaaaaaaaaaaaaaaaa-0`, 'users/other/profile/photo'];
for (const p of paths) await bucket.file(p).save('x');
await fnAccountWithdraw.run(as(U, {}));
const exists = await Promise.all(paths.map(async (p) => (await bucket.file(p).exists())[0]));
expect(exists).toEqual([false, false, true, true]);
await bucket.file(paths[2]!).delete();
await bucket.file(paths[3]!).delete();
});
});
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);
});
});