import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; import { fnFandomJoin, fnFandomLeave, fnNebulaCreate, fnPlanetCreate, fnPrimaryFandomSet, isValidNebulaName, } from '../src/account/account.js'; /** 성운 생성 → 가입 · 대표 팬덤 → 행성 탄생 — 원장 B-08 · B-09 · B-10 · C-15 · C-50. */ const UID = 'test-planet-create'; const OTHER = 'test-planet-create-other'; const db = () => getFirestore(); /** 이 파일이 만든 것만 지운다 — 같은 에뮬레이터를 쓰는 다른 테스트의 문서를 건드리지 않는다. */ const created = { fandoms: new Set(), names: new Set() }; async function call(fn: { run(request: never): unknown }, data: Record, uid = UID) { const out = (await fn.run({ data, auth: { uid, token: {} } } as unknown as never)) as Record; if (fn === fnNebulaCreate) { created.fandoms.add(out.fandomId as string); created.names.add(String(data.name).toLowerCase()); } return out; } async function codeOf(run: () => Promise): Promise { try { await run(); } catch (error) { return (error as { details?: { code?: string } }).details?.code ?? (error as { code?: string }).code ?? '?'; } throw new Error('실패해야 하는 호출이 성공했습니다.'); } const user = async (uid = UID) => (await db().doc(`users/${uid}`).get()).data() ?? {}; const fandom = async (id: string) => (await db().doc(`fandoms/${id}`).get()).data() ?? {}; beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); afterEach(async () => { for (const uid of [UID, OTHER]) { await wipeUser(uid); await db().recursiveDelete(db().collection(`users/${uid}/nebulaCreates`)); await db().recursiveDelete(db().collection(`users/${uid}/memberships`)); await db().doc(`users/${uid}`).delete(); } for (const id of created.fandoms) await db().recursiveDelete(db().doc(`fandoms/${id}`)); for (const name of created.names) await db().doc(`fandomNames/${name}`).delete(); created.fandoms.clear(); created.names.clear(); }); describe('성운 생성', () => { it('Stage0 공용 성운을 만들고 만든 사람이 창립 멤버 #001 · 대표 팬덤이 된다', async () => { const { fandomId, primaryChanged } = await call(fnNebulaCreate, { name: 'BTS RM' }); const f = await fandom(fandomId as string); expect(f).toMatchObject({ name: 'BTS RM', stage: 0, founderUid: UID, memberCount: 1, growthPop: 0 }); expect(f.nebulaDeadline.toMillis() - Date.now()).toBeGreaterThan(6.9 * 86_400_000); expect((await db().doc(`fandoms/${fandomId}/members/${UID}`).get()).get('memberNo')).toBe(1); expect((await db().doc(`users/${UID}/memberships/${fandomId}`).get()).exists).toBe(true); expect(primaryChanged).toBe(true); expect(await user()).toMatchObject({ primaryFandomId: fandomId, fandomId }); }); it('대표 변경 30일 대기 중이면 성운 · 멤버십만 만들고 대표는 그대로 둔다(C-15)', async () => { await db().doc(`users/${UID}`).set({ primaryFandomId: 'old', fandomId: 'old', primaryChangedAt: Timestamp.fromMillis(Date.now() - 86_400_000), }); const { fandomId, primaryChanged } = await call(fnNebulaCreate, { name: 'Cooldown' }); expect(primaryChanged).toBe(false); expect(await user()).toMatchObject({ primaryFandomId: 'old', fandomId: 'old' }); expect((await db().doc(`fandoms/${fandomId}/members/${UID}`).get()).exists).toBe(true); }); it('같은 이름(대소문자 무시)은 E-FDM-01 · 하루 두 번째 생성은 E-FDM-02', async () => { await call(fnNebulaCreate, { name: 'Aurora' }); expect(await codeOf(() => call(fnNebulaCreate, { name: 'aurora' }, OTHER))).toBe('E-FDM-01'); expect(await codeOf(() => call(fnNebulaCreate, { name: 'Borealis' }))).toBe('E-FDM-02'); }); it('이름 규칙 — 2~20자 · 한글/영문/숫자/공백 · 연속·앞뒤 공백 불가', () => { expect(isValidNebulaName('방탄 소년단1')).toBe(true); for (const bad of ['A', ' AB', 'AB ', 'A B', 'AB!', 'x'.repeat(21)]) { expect(isValidNebulaName(bad)).toBe(false); } }); }); describe('가입 · 탈퇴 · 대표 팬덤', () => { it('두 번 가입해도 한 번만 센다 · 탈퇴도 한 번만 내린다 · 첫 가입 팬덤이 대표가 된다', async () => { const { fandomId } = await call(fnNebulaCreate, { name: 'Joinable' }); const id = fandomId as string; expect((await call(fnFandomJoin, { fandomId: id }, OTHER)).joined).toBe(true); expect((await call(fnFandomJoin, { fandomId: id }, OTHER)).joined).toBe(false); expect(await fandom(id)).toMatchObject({ memberCount: 2, memberSeq: 2 }); expect((await db().doc(`fandoms/${id}/members/${OTHER}`).get()).get('memberNo')).toBe(2); expect(await user(OTHER)).toMatchObject({ primaryFandomId: id }); expect((await call(fnFandomLeave, { fandomId: id }, OTHER)).left).toBe(true); expect((await call(fnFandomLeave, { fandomId: id }, OTHER)).left).toBe(false); expect((await fandom(id)).memberCount).toBe(1); expect(await user(OTHER)).toMatchObject({ primaryFandomId: null, fandomId: '' }); expect((await db().doc(`users/${OTHER}/memberships/${id}`).get()).exists).toBe(false); // LIVE 집계용 가입 목록 비정규화 — 가입 · 탈퇴 트랜잭션이 함께 유지한다. expect((await user(OTHER)).fandomIds).toEqual([]); expect((await user()).fandomIds).toEqual([id]); }); it('없는 팬덤 가입 · 가입하지 않은 팬덤을 대표로 지정은 거부한다', async () => { expect(await codeOf(() => call(fnFandomJoin, { fandomId: 'missing-fandom' }))).toBe('E-PERM-01'); const { fandomId } = await call(fnNebulaCreate, { name: 'NotMine' }, OTHER); expect(await codeOf(() => call(fnPrimaryFandomSet, { fandomId }))).toBe('E-PERM-01'); }); it('대표 변경은 무료 · 30일 안 재변경은 E-FDM-04 · 대기가 지나면 허용', async () => { const a = (await call(fnNebulaCreate, { name: 'Primary A' }, OTHER)).fandomId as string; await call(fnFandomJoin, { fandomId: a }); // 대표가 없던 계정의 첫 지정(가입)은 변경이 아니라 대기 시각을 찍지 않는다. expect((await user()).primaryChangedAt).toBeUndefined(); // 성운 생성 = 대표 변경(A → B). 여기서 대기가 시작된다. const b = (await call(fnNebulaCreate, { name: 'Primary B' })).fandomId as string; expect(await user()).toMatchObject({ primaryFandomId: b }); expect((await user()).primaryChangedAt).toBeInstanceOf(Timestamp); expect(await codeOf(() => call(fnPrimaryFandomSet, { fandomId: a }))).toBe('E-FDM-04'); await db().doc(`users/${UID}`).update({ primaryChangedAt: Timestamp.fromMillis(Date.now() - 31 * 86_400_000) }); expect((await call(fnPrimaryFandomSet, { fandomId: a })).changed).toBe(true); expect(await user()).toMatchObject({ primaryFandomId: a, fandomId: a }); }); }); describe('창립자 탈퇴 승계 · 대표 팬덤 자동 지정(원장 C-100 · C-101 · S20)', () => { const setPolicy = (patch: Record) => db().doc('config/policy').set(patch, { merge: true }); const THIRD = 'test-planet-create-third'; afterEach(async () => { await db().doc('config/policy').set({ primaryOnLeave: FieldValue.delete() }, { merge: true }); await wipeUser(THIRD); await db().recursiveDelete(db().collection(`users/${THIRD}/memberships`)); await db().recursiveDelete(db().collection(`users/${OTHER}/notifications`)); await db().recursiveDelete(db().collection(`users/${THIRD}/notifications`)); }); it('탄생 전 창립자 탈퇴 허용 → 가장 먼저 가입한 남은 멤버가 탄생 권한자 · 알림 · 이전 창립자 탄생 거부 · 순번 비재사용', async () => { const id = (await call(fnNebulaCreate, { name: 'Founder Go' })).fandomId as string; await call(fnFandomJoin, { fandomId: id }, OTHER); await call(fnFandomJoin, { fandomId: id }, THIRD); // 가입 시각을 OTHER < THIRD 로 못박는다. await db().doc(`fandoms/${id}/members/${OTHER}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 2 * 86_400_000) }); await db().doc(`fandoms/${id}/members/${THIRD}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 86_400_000) }); const out = await call(fnFandomLeave, { fandomId: id }); expect(out).toMatchObject({ left: true, founderUid: OTHER }); expect(await fandom(id)).toMatchObject({ founderUid: OTHER, memberCount: 2, memberSeq: 3 }); const notes = await db().collection(`users/${OTHER}/notifications`).get(); expect(notes.docs.map((d) => d.get('title'))).toEqual(['「Founder Go」 성운의 행성 탄생 권한을 이어받았어요.']); await db().doc(`fandoms/${id}`).update({ memberCount: 10, growthPop: 1_000 }); const pick = { fandomId: id, planetType: 'shape1', colorKey: 'blue' }; expect(await codeOf(() => call(fnPlanetCreate, pick))).toBe('E-PERM-01'); // 떠난 창립자가 다시 가입해도 #001 이 아니라 다음 순번(#4)을 받는다. await call(fnFandomJoin, { fandomId: id }); expect((await db().doc(`fandoms/${id}/members/${UID}`).get()).get('memberNo')).toBe(4); expect((await fandom(id)).founderUid).toBe(OTHER); await call(fnPlanetCreate, pick, OTHER); expect((await fandom(id)).stage).toBe(1); }); it('창립자 혼자인 성운 — 탈퇴 허용 · 성운 유지 · 탄생 권한은 비웠다가 다음 가입자에게(S20)', async () => { const id = (await call(fnNebulaCreate, { name: 'Founder Alone' })).fandomId as string; expect(await call(fnFandomLeave, { fandomId: id })).toMatchObject({ left: true, founderUid: null }); expect(await fandom(id)).toMatchObject({ stage: 0, memberCount: 0, founderUid: null, name: 'Founder Alone' }); await call(fnFandomJoin, { fandomId: id }, OTHER); expect((await fandom(id)).founderUid).toBe(OTHER); // 이미 권한자가 있으면 다음 가입자는 이어받지 않는다. await call(fnFandomJoin, { fandomId: id }, THIRD); expect((await fandom(id)).founderUid).toBe(OTHER); }); it('탄생 뒤 창립자 탈퇴는 탄생 권한을 건드리지 않는다', async () => { const id = (await call(fnNebulaCreate, { name: 'Founder Born' })).fandomId as string; await call(fnFandomJoin, { fandomId: id }, OTHER); await db().doc(`fandoms/${id}`).update({ stage: 1 }); expect(await call(fnFandomLeave, { fandomId: id })).not.toHaveProperty('founderUid'); expect((await fandom(id)).founderUid).toBe(UID); expect((await db().collection(`users/${OTHER}/notifications`).get()).empty).toBe(true); }); /** A(대표) · B · C 에 가입 — 가입 시각은 B < C 로 못박는다. */ async function joinThree(): Promise<[string, string, string]> { const ids: string[] = []; for (const name of ['leave-a', 'leave-b', 'leave-c']) { // 성운 생성은 하루 1회라 정식 행성 문서를 직접 둔다. const id = `test-planet-create-${name}`; await db().doc(`fandoms/${id}`).set({ name, stage: 1, founderUid: OTHER, memberCount: 0 }); created.fandoms.add(id); await call(fnFandomJoin, { fandomId: id }); ids.push(id); } const [a, b, c] = ids as [string, string, string]; await db().doc(`users/${UID}/memberships/${b}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 2 * 86_400_000) }); await db().doc(`users/${UID}/memberships/${c}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 86_400_000) }); expect(await user()).toMatchObject({ primaryFandomId: a }); return [a, b, c]; } it('대표 팬덤을 떠나면 가장 최근 가입 팬덤이 대표 · 대기에 세지 않는다 · 남은 팬덤이 없으면 비운다', async () => { const [a, b, c] = await joinThree(); expect((await call(fnFandomLeave, { fandomId: a })).primaryFandomId).toBe(c); expect(await user()).toMatchObject({ primaryFandomId: c, fandomId: c }); expect((await user()).primaryChangedAt).toBeUndefined(); // 자동 지정 직후에도 사용자가 바로 바꿀 수 있다. expect((await call(fnPrimaryFandomSet, { fandomId: b })).changed).toBe(true); await call(fnFandomLeave, { fandomId: c }); // 대표가 아니면 대표는 그대로 expect(await user()).toMatchObject({ primaryFandomId: b }); await call(fnFandomLeave, { fandomId: b }); expect(await user()).toMatchObject({ primaryFandomId: null, fandomId: '' }); }); it('primaryOnLeave — earliest 는 가장 먼저 가입한 팬덤 · none 은 비워 둔다', async () => { const [a, b] = await joinThree(); await setPolicy({ primaryOnLeave: 'earliest' }); await call(fnFandomLeave, { fandomId: a }); expect(await user()).toMatchObject({ primaryFandomId: b }); await setPolicy({ primaryOnLeave: 'none' }); await call(fnFandomLeave, { fandomId: b }); expect(await user()).toMatchObject({ primaryFandomId: null }); }); }); describe('행성 탄생', () => { async function nebula(members: number, pop: number): Promise { const { fandomId } = await call(fnNebulaCreate, { name: `N${members}x${pop}` }); await db().doc(`fandoms/${fandomId}`).update({ memberCount: members, growthPop: pop }); return fandomId as string; } const pick = (fandomId: string) => ({ fandomId, planetType: 'shape2', colorKey: 'gold' }); it('없는 성운은 거부 · 한쪽 조건만 채우면 E-FDM-03(B-10)', async () => { expect(await codeOf(() => call(fnPlanetCreate, pick('missing-nebula')))).toBe('E-PERM-01'); const id = await nebula(9, 5_000); expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03'); await db().doc(`fandoms/${id}`).update({ memberCount: 10, growthPop: 999 }); expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03'); }); it('7일 목표는 설정이 켜졌을 때만 막는다(nebulaConversionDeadlineEnforced)', async () => { const id = await nebula(10, 1_000); await db().doc(`fandoms/${id}`).update({ nebulaDeadline: Timestamp.fromMillis(Date.now() - 1_000) }); await db().doc('config/policy').set({ nebulaConversionDeadlineEnforced: true }, { merge: true }); try { expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03'); } finally { await db().doc('config/policy').update({ nebulaConversionDeadlineEnforced: FieldValue.delete() }); } await call(fnPlanetCreate, pick(id)); expect((await fandom(id)).stage).toBe(1); }); it('10명 + 1,000 POP 이면 Stage1 이 되고 형태·컬러는 한 번만 저장된다(C-50)', async () => { const id = await nebula(10, 1_000); expect(await codeOf(() => call(fnPlanetCreate, pick(id), OTHER))).toBe('E-PERM-01'); await call(fnPlanetCreate, pick(id)); expect(await fandom(id)).toMatchObject({ stage: 1, planetType: 'shape2', colorKey: 'gold' }); expect((await db().doc(`fandoms/${id}/planets/planet`).get()).get('ownerUid')).toBe(UID); // 두 번째 생성(= 외형 변경 시도)은 거부된다. expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: 'red' }))).toBe('E-POP-03'); }); it('팔레트 밖 형태·색은 받지 않는다', async () => { const id = await nebula(10, 1_000); expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: '#123456' }))) .toBe('invalid-argument'); }); });