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

278 lines
15 KiB
TypeScript

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<string>(), names: new Set<string>() };
async function call(fn: { run(request: never): unknown }, data: Record<string, unknown>, uid = UID) {
const out = (await fn.run({ data, auth: { uid, token: {} } } as unknown as never)) as Record<string, unknown>;
if (fn === fnNebulaCreate) {
created.fandoms.add(out.fandomId as string);
created.names.add(String(data.name).toLowerCase());
}
return out;
}
async function codeOf(run: () => Promise<unknown>): Promise<string> {
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<string, unknown>) => 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<string> {
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');
});
});