캠페인 성공 알림 · 운영 퀘스트 참여 인원 · Music Shake 하루 1회 · 함수 배포 전 빌드
This commit is contained in:
@@ -7,7 +7,7 @@ import { fundCampaign } from '../src/fandom/fandom.js';
|
||||
import { createCampaign, retryFeeRefunds, withdrawCampaign } from '../src/campaign/create.js';
|
||||
import {
|
||||
MAX_AUTO_REFUND_ATTEMPTS, cancelCampaign, fnCampaignAdminReview, fnCampaignReportSubmit, refundCampaign,
|
||||
retryPendingRefunds, settleDueCampaigns, submitCampaignReport,
|
||||
notifySuccess, retryPendingRefunds, settleDueCampaigns, submitCampaignReport,
|
||||
} from '../src/campaign/settle.js';
|
||||
|
||||
/**
|
||||
@@ -313,6 +313,17 @@ describe('정산 분기', () => {
|
||||
expect(short.get('status')).toBe('fail');
|
||||
expect(short.get('refundState')).toBe('pending');
|
||||
expect(short.get('refundDueAt').toMillis() - Date.now()).toBeGreaterThan(71 * 3600_000);
|
||||
|
||||
// B-3 8-10 성공 알림 — 개설자 · 후원자 한 건씩(kind campaign · essential), 실패 캠페인은 성공 알림 없음.
|
||||
for (const uid of [CREATOR, A, B]) expect(await notes(uid)).toEqual(['campaignSuccess_cmp-ok']);
|
||||
const note = await db().doc(`users/${A}/notifications/campaignSuccess_cmp-ok`).get();
|
||||
expect(note.data()).toMatchObject({ eventType: 'campaign', type: 'essential', campaignId: 'cmp-ok' });
|
||||
expect(note.get('title')).toBe('후원하신 「캠페인 cmp-ok」 캠페인이 목표를 달성했습니다');
|
||||
expect((await db().doc(`users/${CREATOR}/notifications/campaignSuccess_cmp-ok`).get()).get('title'))
|
||||
.toBe('개설하신 「캠페인 cmp-ok」 캠페인이 목표를 달성했습니다');
|
||||
// 재호출은 같은 문서 — 중복 알림 0건.
|
||||
await notifySuccess('cmp-ok');
|
||||
expect(await notes(A)).toEqual(['campaignSuccess_cmp-ok']);
|
||||
});
|
||||
|
||||
it('「응원 중」(donorCount)은 고유 후원자 수다 — 같은 사람의 두 번째 후원은 늘리지 않는다', async () => {
|
||||
@@ -467,7 +478,7 @@ describe('결과보고 — 운영 전용 (B-35)', () => {
|
||||
const doc = await db().doc('campaigns/cmp-rep/reports/main').get();
|
||||
expect(doc.get('receiptPaths')).toEqual(['campaignReports/cmp-rep/r1.png']);
|
||||
expect(doc.get('budgetTotal')).toBe(500);
|
||||
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']);
|
||||
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main', 'campaignSuccess_cmp-rep']);
|
||||
|
||||
// 게시 뒤 알림 전에 죽은 경우.
|
||||
await db().recursiveDelete(db().collection(`users/${A}/notifications`));
|
||||
|
||||
@@ -602,7 +602,7 @@ describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Camp
|
||||
expect(reply.rewarded).toBe(4);
|
||||
});
|
||||
|
||||
it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => {
|
||||
it('Music Shake 는 같은 날 두 번 완주해도 1회(C-128) · 방문은 1회', async () => {
|
||||
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
|
||||
const shake = () => call(fnQuestVerifyMusicShake, {
|
||||
questId: 'music-count', trackId: 't1', totalSegments: 10,
|
||||
@@ -610,9 +610,11 @@ describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Camp
|
||||
});
|
||||
await shake();
|
||||
await shake();
|
||||
expect(await count()).toBe(2);
|
||||
// 보상은 한도 2회만큼 두 번 나가지만 검증 활동은 하루 1회.
|
||||
expect((await db().collection(`users/${UID}/popTransactions`).where('reasonCode', '==', 'QUEST_MUSIC_SHAKE').get()).size).toBe(2);
|
||||
expect(await count()).toBe(1);
|
||||
await call(fnQuestVerifyPlace, { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 });
|
||||
expect(await count()).toBe(3);
|
||||
expect(await count()).toBe(2);
|
||||
});
|
||||
|
||||
it('무효 처리는 원장 행으로 찾아 한 번만 빠진다 · 운영자만', async () => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||
|
||||
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||
import { countParticipant, participateStreaming } from '../src/quest/quest.js';
|
||||
import { TEST_PAYOUT_MARK, popIssuance } from '../src/admin/admin.js';
|
||||
|
||||
/**
|
||||
* 운영 Quest 「참여 인원」(10/6 관찰 「참여 인원 0」) · C-123 테스트 지급분 집계 제외.
|
||||
* 트리거 본체([countParticipant])를 진행 문서 값으로 직접 부른다 — 에뮬레이터에 함수 트리거가 없다.
|
||||
*/
|
||||
|
||||
const A = 'test-part-a';
|
||||
const B = 'test-part-b';
|
||||
const db = () => getFirestore();
|
||||
const countOf = async (id: string) => (await db().doc(`quests/${id}`).get()).get('participantCount') as number | undefined;
|
||||
|
||||
beforeAll(() => { bootAdmin(); });
|
||||
afterAll(async () => { await shutdownAdmin(); });
|
||||
beforeEach(async () => {
|
||||
for (const uid of [A, B]) await wipeUser(uid);
|
||||
for (const id of ['op-stream', 'op-shake', 'ugc-q']) await db().recursiveDelete(db().doc(`quests/${id}`));
|
||||
await db().doc(`users/${A}`).set({ uid: A });
|
||||
await db().doc(`users/${B}`).set({ uid: B });
|
||||
});
|
||||
|
||||
describe('운영 Quest 참여 인원 — 사람 단위 · 멱등', () => {
|
||||
it('완료 진행 문서로 1명 · 같은 사람 재전달 · 다음 날 재참여는 그대로 · 다른 사람은 +1', async () => {
|
||||
await db().doc('quests/op-stream').set({ type: 'streaming', title: '스밍', dailyLimit: 1 });
|
||||
await participateStreaming(A, 'op-stream');
|
||||
const progress = (await db().doc(`users/${A}/questProgress/op-stream`).get()).data()!;
|
||||
expect(await countParticipant(A, 'op-stream', progress)).toBe(true);
|
||||
expect(await countParticipant(A, 'op-stream', progress)).toBe(false);
|
||||
expect(await countParticipant(A, 'op-stream', { count: 1 })).toBe(false);
|
||||
expect(await countOf('op-stream')).toBe(1);
|
||||
expect(await countParticipant(B, 'op-stream', { count: 1 })).toBe(true);
|
||||
expect(await countOf('op-stream')).toBe(2);
|
||||
});
|
||||
|
||||
it('진행 세션 시작(Music Shake 「참여」)도 1명 · 빈 진행 문서는 세지 않는다', async () => {
|
||||
await db().doc('quests/op-shake').set({ type: 'musicShake', title: '셰이크' });
|
||||
expect(await countParticipant(A, 'op-shake', {})).toBe(false);
|
||||
expect(await countParticipant(A, 'op-shake', { session: { state: 'inProgress' } })).toBe(true);
|
||||
expect(await countParticipant(A, 'op-shake', { count: 1 })).toBe(false);
|
||||
expect(await countOf('op-shake')).toBe(1);
|
||||
});
|
||||
|
||||
it('사용자 생성 Quest · 퀘스트 문서가 없는 진행 문서(ad)는 건너뛴다', async () => {
|
||||
await db().doc('quests/ugc-q').set({ type: 'place', authorUid: 'someone', participantCount: 3 });
|
||||
expect(await countParticipant(A, 'ugc-q', { count: 1 })).toBe(false);
|
||||
expect(await countOf('ugc-q')).toBe(3);
|
||||
expect(await countParticipant(A, 'ad', { count: 1 })).toBe(false);
|
||||
expect((await db().doc('quests/ad').get()).exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C-123 테스트 지급분 — 원장 불변 · 발행량 대조에서 제외', () => {
|
||||
const at = (h: number) => Timestamp.fromDate(new Date(Date.UTC(2020, 0, 1, h)));
|
||||
const row = (uid: string, id: string, data: Record<string, unknown>) =>
|
||||
db().doc(`users/${uid}/popTransactions/${id}`).set({ type: 'grant', ...data });
|
||||
|
||||
it('표시된 행은 bySource 에서 빠지고 testPayout 에 따로 · 원장 행은 그대로', async () => {
|
||||
await row(A, 'tx-test', { popType: 'activity', reasonCode: 'AD_REWARD', amount: 10, createdAt: at(1) });
|
||||
await row(A, 'tx-ok', { popType: 'support', reasonCode: 'AD_REWARD', amount: 10, createdAt: at(2) });
|
||||
await row(B, 'tx-attend', { popType: 'activity', reasonCode: 'QUEST_ATTEND', amount: 10, createdAt: at(3) });
|
||||
await db().doc('popTransactionMarks/tx-test').set({
|
||||
mark: TEST_PAYOUT_MARK, reason: 'C-123', path: `users/${A}/popTransactions/tx-test`, markedAt: at(5),
|
||||
});
|
||||
try {
|
||||
const report = await popIssuance(at(0).toDate(), at(4).toDate());
|
||||
expect(report.bySource).toEqual({
|
||||
'grant:support:AD_REWARD': { count: 1, amount: 10 },
|
||||
'grant:activity:QUEST_ATTEND': { count: 1, amount: 10 },
|
||||
});
|
||||
expect(report.testPayout).toEqual({ 'grant:activity:AD_REWARD': { count: 1, amount: 10 } });
|
||||
// 원장 행은 수정 · 삭제 0건.
|
||||
expect((await db().doc(`users/${A}/popTransactions/tx-test`).get()).data())
|
||||
.toMatchObject({ amount: 10, popType: 'activity', reasonCode: 'AD_REWARD' });
|
||||
} finally {
|
||||
await db().doc('popTransactionMarks/tx-test').delete();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -159,7 +159,8 @@ describe('회차 참여 — fn-quest-timeattack', () => {
|
||||
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
||||
expect(ledger.size).toBe(1);
|
||||
expect(ledger.docs[0]!.get('reasonCode')).toBe('QUEST_TIMEATTACK');
|
||||
expect((await db().doc('quests/ta-open').get()).get('participantCount')).toBe(1);
|
||||
// 운영 Quest 의 참여 인원은 진행 문서 트리거([countParticipant])가 사람 단위로 센다 — 회차 함수는 올리지 않는다.
|
||||
expect((await db().doc('quests/ta-open').get()).get('participantCount')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('같은 원 행동의 중복 FPI 금지 — 회차 참여가 원 Quest 그날 참여를 겸한다', async () => {
|
||||
|
||||
Reference in New Issue
Block a user