Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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('창립자 탈퇴 · 대표 팬덤 자동 지정(Q-PN-03 · Q-PN-04 권고안)', () => {
|
||||
const setPolicy = (patch: Record<string, unknown>) => db().doc('config/policy').set(patch, { merge: true });
|
||||
afterEach(async () => {
|
||||
await db().doc('config/policy').set({
|
||||
nebulaFounderLeaveBlocked: FieldValue.delete(), primaryOnLeave: FieldValue.delete(),
|
||||
}, { merge: true });
|
||||
});
|
||||
|
||||
it('성운 창립자는 탄생 전 탈퇴 E-FDM-05 · 탄생 뒤에는 탈퇴 가능 · 설정을 끄면 막지 않는다', async () => {
|
||||
const id = (await call(fnNebulaCreate, { name: 'Founder Stay' })).fandomId as string;
|
||||
expect(await codeOf(() => call(fnFandomLeave, { fandomId: id }))).toBe('E-FDM-05');
|
||||
expect((await fandom(id)).memberCount).toBe(1);
|
||||
|
||||
await db().doc(`fandoms/${id}`).update({ stage: 1 });
|
||||
expect((await call(fnFandomLeave, { fandomId: id })).left).toBe(true);
|
||||
|
||||
const other = (await call(fnNebulaCreate, { name: 'Founder Go' }, OTHER)).fandomId as string;
|
||||
await setPolicy({ nebulaFounderLeaveBlocked: false });
|
||||
expect((await call(fnFandomLeave, { fandomId: other }, OTHER)).left).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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user