1차 보완(0.1.1+14) 내용 합치기

This commit is contained in:
Sn-Kinos
2026-10-16 17:58:55 +09:00
62 changed files with 2480 additions and 447 deletions
@@ -0,0 +1,71 @@
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 } from './support.js';
import { fnAuthOnCreate } from '../src/account/account.js';
import { fnAuthKakao, kakaoUidOf } from '../src/account/kakao.js';
/** 이메일 선택 수집 — 동의한 이메일만 남기고, 이메일이 없어도 가입 · 로그인된다. */
const U = 'test-email-user';
const KAKAO_ID = '555000111';
const db = () => getFirestore();
type KakaoReq = Parameters<typeof fnAuthKakao.run>[0];
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}`));
}
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('제공자 정보의 이메일을 쓰고 · 이미 쓴 값은 덮지 않는다', async () => {
await db().doc(`users/${U}`).set({ displayName: '먼저씀' });
await onCreate(U, { providerData: [{ providerId: 'oidc.line', email: 'line@fan.io' }] });
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.displayName).toBe('먼저씀');
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);
});
it('이메일이 없으면 null 로 만든다', async () => {
await onCreate(U, { providerData: [{ providerId: 'oidc.line' }] });
expect((await db().doc(`users/${U}`).get()).get('email')).toBeNull();
});
});
describe('카카오 이메일', () => {
it('동의한 이메일만 사용자 문서에 남기고 · 없어도 로그인된다', async () => {
stubKakao();
const first = await fnAuthKakao.run({ data: { accessToken: 'good' } } as KakaoReq) as { token: string };
expect(first.token).toBeTruthy();
expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBeUndefined();
stubKakao('fan@kakao.com');
await fnAuthKakao.run({ data: { accessToken: 'good' } } as KakaoReq);
expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBe('fan@kakao.com');
});
});
@@ -4,7 +4,9 @@ import { getFirestore } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { fnAccountWithdraw, fnAuthOnCreate, fnSignupConsent } from '../src/account/account.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). */
@@ -66,20 +68,57 @@ describe('계정 생성 트리거', () => {
});
});
describe('fnSignupConsent', () => {
it('필수 하나라도 빠지면 거부', async () => {
const r = fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'location', 'age'] }));
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('항목별 행 · termsAgreedAt · 마케팅 = Push Marketing', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'location', 'age'] }));
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', 'location_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
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);
});
});
+30 -3
View File
@@ -96,11 +96,38 @@ describe('SNS 연동', () => {
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
});
it('이메일이 다르면 붙이지 않는다(different-email) · 카카오 이메일이 없으면 비교를 건너뛴다', async () => {
it('이메일이 달라도 · 카카오 이메일이 없어도 연결한다 — 이메일 일치는 소유 확인이 아니다(Q-E8 · R01)', async () => {
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
stubKakao('other@kakao.com');
expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('different-email');
stubKakao('FAN@fanuzu.co.kr');
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
await getAuth().deleteUser(GOOGLE_UID);
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
stubKakao();
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
const user = await getAuth().getUser(GOOGLE_UID);
expect(user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID && d.uid === KAKAO_ID)).toBe(true);
});
it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다(Q-E8 세션 만료 · 인증 취소)', async () => {
await getAuth().createUser({ uid: GOOGLE_UID });
const before = Math.floor(Date.now() / 1000) - 60;
await getAuth().revokeRefreshTokens(GOOGLE_UID);
const linkAs = (authTime: number) => fnAuthKakao.run(
{ data: { accessToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req,
);
expect(await codeOf(linkAs(before))).toBe('session');
await getAuth().updateUser(GOOGLE_UID, { disabled: true });
expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session');
expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]);
});
it('이미 다른 카카오가 붙어 있으면 E-AUTH-09 · 같은 카카오면 그대로 성공', async () => {
await getAuth().createUser({ uid: GOOGLE_UID });
await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: '111' } });
expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09');
await getAuth().deleteUser(GOOGLE_UID);
await getAuth().createUser({ uid: GOOGLE_UID });
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
});
});
@@ -0,0 +1,160 @@
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 } from './support.js';
import { fnAuthLine, LINE_PROVIDER_ID, lineUidOf } from '../src/account/line.js';
/** LINE 커스텀 토큰 — ID 토큰 · 액세스 토큰 검증 · 기존 oidc.line 사용자 매핑 · 신규 UID · 이메일(C-143) · SNS 연동. */
const CHANNEL = '2000000001';
const LINE_ID = 'U0123456789abcdef0123456789abcdef';
const OLD_UID = 'test-line-oidc-user';
const GOOGLE_UID = 'test-line-google-user';
type Req = Parameters<typeof fnAuthLine.run>[0];
const call = (data: Record<string, unknown>, uid?: string) =>
fnAuthLine.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req);
const codeOf = async (p: Promise<unknown>) => {
try { await p; return 'ok'; } catch (e) {
const d = (e as { details?: { code?: string; reason?: string } }).details;
return d?.reason ?? d?.code;
}
};
const uidOf = (token: string) => JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid as string;
/**
* LINE API 흉내. ID 토큰 'good' · 액세스 토큰 'good' 만 우리 채널 것, 'other-app' 은 남의 채널, 'expired' 는 만료.
* 실제 LINE 처럼 ID 토큰 검증은 보낸 client_id 와 aud 가 다르면 400 이다.
*/
function stubLine(email?: string) {
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: { method?: string; body?: string; headers?: Record<string, string> }) => {
if (init?.method === 'POST' && url.endsWith('/oauth2/v2.1/verify')) {
const form = new URLSearchParams(init.body);
const token = form.get('id_token');
if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 });
const aud = token === 'other-app' ? '1' : CHANNEL;
if (form.get('client_id') !== aud) return Response.json({ error: 'invalid_request' }, { status: 400 });
return Response.json({ iss: 'https://access.line.me', sub: LINE_ID, aud, name: 'LINE팬', ...(email ? { email } : {}) });
}
if (url.includes('/oauth2/v2.1/verify?access_token=')) {
const token = new URL(url).searchParams.get('access_token');
if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 });
return Response.json({ scope: 'profile openid', client_id: token === 'other-app' ? '1' : CHANNEL, expires_in: 100 });
}
if (url.endsWith('/v2/profile')) return Response.json({ userId: LINE_ID, displayName: 'LINE팬' });
return new Response('{}', { status: 404 });
}));
}
async function wipe() {
await getAuth().deleteUsers([OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]);
for (const uid of [OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]) await getFirestore().doc(`users/${uid}`).delete();
}
beforeAll(() => {
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) {
throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.');
}
process.env.LINE_CHANNEL_ID = CHANNEL;
bootAdmin();
});
afterAll(async () => { await wipe(); await shutdownAdmin(); });
beforeEach(async () => { await wipe(); stubLine(); });
afterEach(() => { vi.unstubAllGlobals(); });
describe('토큰 검증', () => {
it('만료 · 남의 채널 토큰(ID · 액세스 모두)과 토큰 없음은 E-AUTH-03', async () => {
for (const key of ['idToken', 'accessToken']) {
expect(await codeOf(call({ [key]: 'expired' }))).toBe('E-AUTH-03');
expect(await codeOf(call({ [key]: 'other-app' }))).toBe('E-AUTH-03');
}
expect(await codeOf(call({}))).toBe('E-AUTH-03');
});
it('ID 토큰이 있으면 ID 토큰으로 검증한다(액세스 토큰이 남의 채널 것이어도 보지 않는다)', async () => {
const { token } = await call({ idToken: 'good', accessToken: 'other-app' }) as { token: string };
expect(uidOf(token)).toBe(lineUidOf(LINE_ID));
});
});
describe('로그인', () => {
it('OIDC 시절 oidc.line 가입자는 같은 UID 로 들어온다', async () => {
await getAuth().createUser({ uid: OLD_UID });
await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: LINE_ID } });
const { token } = await call({ idToken: 'good' }) as { token: string };
expect(uidOf(token)).toBe(OLD_UID);
});
it('신규는 line:<userId> 로 만들고 oidc.line 을 붙인다 · 액세스 토큰만으로도 같은 UID', async () => {
const first = await call({ idToken: 'good' }) as { token: string };
const second = await call({ accessToken: 'good' }) as { token: string };
expect(uidOf(first.token)).toBe(lineUidOf(LINE_ID));
expect(uidOf(second.token)).toBe(lineUidOf(LINE_ID));
const user = await getAuth().getUser(lineUidOf(LINE_ID));
expect(user.displayName).toBe('LINE팬');
expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[LINE_PROVIDER_ID, LINE_ID]]);
});
it('이메일은 LINE 이 줄 때(동의)만 사용자 문서에 남긴다 — 없어도 로그인된다(C-143)', async () => {
await call({ idToken: 'good' });
expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBeUndefined();
stubLine('fan@line.me');
await call({ idToken: 'good' });
expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBe('fan@line.me');
});
it('탈퇴한 line:<id> 문서가 있으면 새 UID 로 만든다', async () => {
await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).set({ status: 'withdrawn' });
const { token } = await call({ idToken: 'good' }) as { token: string };
const uid = uidOf(token);
expect(uid.startsWith(`${lineUidOf(LINE_ID)}:`)).toBe(true);
await getAuth().deleteUser(uid);
});
});
describe('SNS 연동', () => {
it('로그인 없이 link 는 E-AUTH-03', async () => {
expect(await codeOf(call({ idToken: 'good', link: true }))).toBe('E-AUTH-03');
});
it('Google 사용자에 붙인다(이메일이 달라도 · 없어도 — Q-E8) · 다른 UID 가 쓰는 LINE 은 E-AUTH-08', async () => {
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
stubLine('other@line.me');
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
const user = await getAuth().getUser(GOOGLE_UID);
expect(user.providerData.some((d) => d.providerId === LINE_PROVIDER_ID && d.uid === LINE_ID)).toBe(true);
await getAuth().createUser({ uid: OLD_UID });
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
});
it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다', async () => {
await getAuth().createUser({ uid: GOOGLE_UID });
const before = Math.floor(Date.now() / 1000) - 60;
await getAuth().revokeRefreshTokens(GOOGLE_UID);
const linkAs = (authTime: number) => fnAuthLine.run(
{ data: { idToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req,
);
expect(await codeOf(linkAs(before))).toBe('session');
await getAuth().updateUser(GOOGLE_UID, { disabled: true });
expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session');
expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]);
});
it('탈퇴 상태 계정으로는 붙이지 않는다', async () => {
await getAuth().createUser({ uid: GOOGLE_UID });
await getFirestore().doc(`users/${GOOGLE_UID}`).set({ status: 'withdrawn' });
expect(await codeOf(call({ idToken: 'good', link: true }, GOOGLE_UID))).toBe('session');
});
it('이미 다른 LINE 이 붙어 있으면 E-AUTH-09 · 같은 LINE 이면 그대로 성공', async () => {
await getAuth().createUser({ uid: GOOGLE_UID });
await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: 'U-other' } });
expect(await codeOf(call({ idToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09');
await getAuth().deleteUser(GOOGLE_UID);
await getAuth().createUser({ uid: GOOGLE_UID });
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
});
});
+21 -1
View File
@@ -87,7 +87,11 @@ beforeEach(async () => {
}
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
await Promise.all(events.docs.map((d) => d.ref.delete()));
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM, totalContributedPop: 0 });
await db().doc(`users/${UID}`).set({
uid: UID, fandomId: FANDOM, totalContributedPop: 0,
// 위치 Quest 인증은 위치기반 서비스 동의가 있어야 한다(Q-E1 · R02). 동의 자체는 아래 별도 시험이 본다.
consentState: { location: { granted: true, version: 'v1' } },
});
// 운영값은 문서로 둔다 — 코드에 박으면 회신마다 재배포다(설계서 §10).
await db().doc('config/policy').set({
attendPop: 10, stepsGoal: 10_000, stepsPop: 50, adRewardPop: 10, adMultiplier: 2, adDailyLimit: 5,
@@ -459,6 +463,22 @@ describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
const inRange = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 };
const farAway = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.4979, lng: 127.0276 };
it('위치기반 서비스 동의가 없으면 좌표를 처리하지 않고 E-PERM-01 (Q-E1 · R02)', async () => {
await db().doc(`users/${UID}`).update({ consentState: FieldValue.delete() });
expect(await codeOf(() => call(fnQuestVerifyPlace, inRange))).toBe('E-PERM-01');
expect((await db().collection(`users/${UID}/activityLogs`).get()).size).toBe(0);
// 철회 상태도 마찬가지다.
await db().doc(`users/${UID}`).update({ consentState: { location: { granted: false, version: 'v1' } } });
expect(await codeOf(() => call(fnQuestVerifyPlace, inRange))).toBe('E-PERM-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
});
it('예전 가입 화면에서 위치 약관에 동의한 사용자는 다시 묻지 않는다', async () => {
await db().doc(`users/${UID}`).update({ consentState: FieldValue.delete(), termsVersion: 'draft-1' });
await db().doc(`users/${UID}/consents/location_draft-1_grant`).set({ docType: 'location', granted: true });
expect((await call(fnQuestVerifyPlace, inRange)).rewarded).toBe(100);
});
it('반경 밖이면 거부하고 거리를 증거에 남긴다', async () => {
expect(await codeOf(
() => call(fnQuestVerifyPlace, { ...farAway, proofKind: 'qr', proof: 'tok-far' }),
@@ -48,6 +48,8 @@ const call = (fn: { run(request: never): unknown }, uid: string, data: Record<st
async function user(uid: string, fandom: string, ageHours = 48) {
await db().doc(`users/${uid}`).set({
fandomId: fandom, primaryFandomId: fandom, nickname: uid, status: 'active',
// 위치 Quest 인증은 위치기반 서비스 동의가 있어야 한다(Q-E1 · R02).
consentState: { location: { granted: true, version: 'v1' } },
createdAt: Timestamp.fromMillis(Date.now() - ageHours * HOUR),
});
}
@@ -0,0 +1,51 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { fnSignupConsent } from '../src/account/account.js';
/** 가입 약관 동의 — 필수 3항 검사 · 항목별 동의 행 · 동의 시각 기록. */
const U = 'test-consent-user';
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) { return (e as { code?: string }).code; }
};
const wipe = () => db().recursiveDelete(db().doc(`users/${U}`));
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await wipe(); await shutdownAdmin(); });
beforeEach(wipe);
describe('fnSignupConsent', () => {
it('필수(서비스 · 개인정보 수집·이용 · 만 14세) 하나라도 빠지면 거부하고 기록하지 않는다', async () => {
expect(await codeOf(fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'age'] }))))
.toBe('invalid-argument');
expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined();
});
it('필수만 동의 — 항목별 행 · 마케팅 기본 OFF · termsAgreedAt', 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.termsVersion).toBe('v1');
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']);
});
it('마케팅 선택 동의는 알림 마케팅 구분과 같은 값이 된다', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age', 'marketing'] }));
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(true);
expect((await db().doc(`users/${U}/consents/marketing_v1_grant`).get()).get('granted')).toBe(true);
});
it('로그인하지 않은 호출은 거부', async () => {
expect(await codeOf(fnSignupConsent.run({ data: { version: 'v1', agreed: [] } } as CallReq)))
.not.toBe('ok');
});
});