diff --git a/app/firebase/firebase.json b/app/firebase/firebase.json index 59ccead..d953744 100644 --- a/app/firebase/firebase.json +++ b/app/firebase/firebase.json @@ -31,7 +31,10 @@ { "source": "functions", "codebase": "default", - "runtime": "nodejs22" + "runtime": "nodejs22", + "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" run build" + ] } ] } diff --git a/app/firebase/firestore.rules b/app/firebase/firestore.rules index 7bf3afd..d3d8e6b 100644 --- a/app/firebase/firestore.rules +++ b/app/firebase/firestore.rules @@ -990,6 +990,12 @@ service cloud.firestore { allow write: if false; } + // C-123 「테스트 지급분」 표시 행(Firestore 설계서 v1.4 §3.4) — 원장을 고치지 않고 덧붙인다. 서버 스크립트만 쓴다. + match /popTransactionMarks/{txId} { + allow read: if isViewer(); + allow write: if false; + } + // ─────────────── 12. 컬렉션 그룹 쿼리 ─────────────── // // Firestore에서 `match /users/{uid}/popTransactions/{txId}` 형태의 규칙은 diff --git a/app/firebase/functions/src/admin/admin.ts b/app/firebase/functions/src/admin/admin.ts index 43372f2..7a32a3f 100644 --- a/app/firebase/functions/src/admin/admin.ts +++ b/app/firebase/functions/src/admin/admin.ts @@ -1,5 +1,5 @@ -import { onCall } from 'firebase-functions/v2/https'; -import { getFirestore, FieldValue } from 'firebase-admin/firestore'; +import { HttpsError, onCall } from 'firebase-functions/v2/https'; +import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore'; import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js'; import { ERR, fail, positiveInt } from '../core/errors.js'; @@ -19,7 +19,7 @@ import { voidCompletions } from '../ledger/fpi.js'; * 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다. */ const FORBIDDEN = [ - /popTransactions/, /popLots/, /\/wallet\//, /fpiSnapshots/, /growthEvents/, + /popTransactions/, /popTransactionMarks/, /popLots/, /\/wallet\//, /fpiSnapshots/, /growthEvents/, /\/fpiEvents\//, /\/eclipseRounds\//, /\/fandomDailyStats\//, /^\/?adminActionLogs\//, ]; @@ -248,3 +248,54 @@ export const fnAdminCompletionVoid = onCall(async (req) => { export async function voidCompletion(uid: string, key: { txId?: string; submissionId?: string }): Promise { return getFirestore().runTransaction(async (tx) => (await voidCompletions(tx, uid, key))(tx)); } + +/** + * C-123 「테스트 지급분」 표시 — `popTransactionMarks/{txId}` 의 `mark` 값(Firestore 설계서 v1.4 §3.4). + * 원장 행은 고치지 않고 표시 행을 덧붙인다. 표시는 운영 스크립트(`tools/demo/mark_test_payout.mjs`)가 쓴다. + */ +export const TEST_PAYOUT_MARK = 'test_payout'; + +interface Tally { count: number; amount: number } +export interface IssuanceReport { + /** `{type}:{popType}:{reasonCode}` → 건수 · 합계. 발행(grant) · 소모(spend) · 환불 · 소멸이 같은 표에 나뉜다. */ + bySource: Record; + /** 판정에서 뺀 테스트 지급분(C-123). 같은 키로 따로 보인다. */ + testPayout: Record; +} + +/** + * 원천별 발행량 · 소모량 대조(C-71) — 기간 [from, to) 의 원장 행을 원천별로 더한다. + * **테스트 지급분 표시 행은 `bySource` 에서 빼고 `testPayout` 에 따로** 모은다(C-123 「검수 · 발행량 판정에서 제외」). + * 잔액 ↔ 원장 대조(`reconcileUser`)는 실제 잔액이라 제외하지 않는다. + * ponytail: 기간의 원장 행을 한 번에 읽는다. 행이 수십만이 되면 일 단위 집계 문서로. + */ +export async function popIssuance(from: Date, to: Date): Promise { + const db = getFirestore(); + const [rows, marks] = await Promise.all([ + db.collectionGroup('popTransactions') + .where('createdAt', '>=', Timestamp.fromDate(from)).where('createdAt', '<', Timestamp.fromDate(to)).get(), + db.collection('popTransactionMarks').where('mark', '==', TEST_PAYOUT_MARK).get(), + ]); + const marked = new Set(marks.docs.map((m) => m.get('path') as string)); + const report: IssuanceReport = { bySource: {}, testPayout: {} }; + for (const r of rows.docs) { + const key = `${r.get('type')}:${r.get('popType')}:${r.get('reasonCode')}`; + const bucket = marked.has(r.ref.path) ? report.testPayout : report.bySource; + const t = (bucket[key] ??= { count: 0, amount: 0 }); + t.count += 1; + t.amount += (r.get('amount') as number | undefined) ?? 0; + } + return report; +} + +/** `fn-admin-pop-issuance` — 조회 등급 이상. 입력 `from` · `to`(ISO). 최대 31일. */ +export const fnAdminPopIssuance = onCall(async (req) => { + requireRole(req, ['master', 'ops', 'viewer']); + const from = new Date(str(req.data, 'from')); + const to = new Date(str(req.data, 'to')); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to <= from + || to.getTime() - from.getTime() > 31 * 86_400_000) { + throw new HttpsError('invalid-argument', '조회 기간은 31일 이내의 올바른 ISO 시각이어야 합니다.'); + } + return popIssuance(from, to); +}); diff --git a/app/firebase/functions/src/campaign/settle.ts b/app/firebase/functions/src/campaign/settle.ts index 4781e15..a067e2d 100644 --- a/app/firebase/functions/src/campaign/settle.ts +++ b/app/firebase/functions/src/campaign/settle.ts @@ -113,10 +113,39 @@ export async function settleDueCampaigns(now: Timestamp = Timestamp.now()): Prom if (success === null) continue; settled += 1; logger.info('[campaign] 정산', { id: doc.id, success }); + if (success) await notifySuccess(doc.id); } return settled; } +/** + * 성공 알림 — B-3 8-10 「캠페인 성공 Push」. 개설자 1건 · 후원자(고유 uid) 1건씩. 알림함에 쌓이면 + * `fnPushInbox` 가 같은 문구로 Push 를 보낸다(`kind = campaign` → 앱 C-78 「결과」 단계). + * 문서 ID 가 캠페인마다 고정이라 다시 불러도 한 사람에게 한 건이다. + * ponytail: 판정 트랜잭션과 알림 사이에 함수가 죽으면 그 회차 알림은 빠진다(상태가 이미 success). + * 빠짐이 문제가 되면 `successNotifiedAt` 표식 + 재시도 패스로. + */ +export async function notifySuccess(campaignId: string): Promise { + const ref = getFirestore().doc(`campaigns/${campaignId}`); + const [campaign, donations] = await Promise.all([ref.get(), ref.collection('donations').get()]); + const title = (campaign.get('title') as string | undefined) ?? '캠페인'; + const creator = campaign.get('creatorUid') as string | undefined; + const donors = new Set(donations.docs + .map((d) => d.get('uid') as string | undefined) + .filter((u): u is string => !!u && u !== creator)); + if (creator) { + await notifyOnce(creator, `campaignSuccess_${campaignId}`, { + title: `개설하신 「${title}」 캠페인이 목표를 달성했습니다`, campaignId, + }); + } + for (const donor of donors) { + await notifyOnce(donor, `campaignSuccess_${campaignId}`, { + title: `후원하신 「${title}」 캠페인이 목표를 달성했습니다`, campaignId, + }); + } + return donors.size + (creator ? 1 : 0); +} + /** * 환불이 끝나지 않은 실패 캠페인을 돌린다 — 방금 실패로 판정된 건도 여기서 첫 환불을 받는다. * 자동 시도 상한을 넘긴 건은 Admin 큐에 있으므로 건드리지 않는다. 실제로 돈 건수를 돌려준다. diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index ea9a080..c914824 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -7,7 +7,7 @@ export { fnPopGrant, fnPopSpend, fnPopRefund, fnPopReconcile, } from './ledger/pop.js'; export { - fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate, fnQuestProgress, + fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate, fnQuestProgress, fnQuestParticipants, } from './quest/quest.js'; export { fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate, @@ -19,7 +19,9 @@ export { fnFpiAggregate, fnLiveAggregate, fnFpiDailySnapshot, fnRankingCountryAggregate, fnCampaignFund, } from './fandom/fandom.js'; -export { fnAdminWrite, fnAdminPopGrantBatch, fnAdminGrowthReverse, fnAdminCompletionVoid } from './admin/admin.js'; +export { + fnAdminWrite, fnAdminPopGrantBatch, fnAdminGrowthReverse, fnAdminCompletionVoid, fnAdminPopIssuance, +} from './admin/admin.js'; export { fnIapVerify, fnIapRefundWebhook, fnSubscriptionWebhook, fnFocusDailyClaim, } from './payment/iap.js'; diff --git a/app/firebase/functions/src/ledger/fpi.ts b/app/firebase/functions/src/ledger/fpi.ts index 85af61c..3b1ac75 100644 --- a/app/firebase/functions/src/ledger/fpi.ts +++ b/app/firebase/functions/src/ledger/fpi.ts @@ -121,8 +121,8 @@ export function writeUserExp(tx: Transaction, uid: string, user: DocumentSnapsho * 「검증 활동」 1회 — 해석 확인 요청 Campaign 개설자격 **A안** · 집계 규칙(2026-09-28 결정). * * 서버가 인정한 Quest 완료를 **완료 단위 표식** `users/{uid}/verifiedCompletions/{id}` 하나로 남긴다. - * `id` 가 곧 「하루 1회」 경계다 — 출석 · 걸음(구간 무관) · 방문 · UGC · 타임어택은 - * `{questId}_{dayKey}`, Music Shake 는 완주마다 `{questId}_{dayKey}_{n}`. 같은 날 같은 표식을 + * `id` 가 곧 「하루 1회」 경계다 — 모든 Quest(출석 · 걸음(구간 무관) · Music Shake · 방문 · UGC · 타임어택)가 + * `{questId}_{dayKey}` 다(C-128 「동일 반복 Quest 는 같은 날 1회」). 같은 날 같은 표식을 * 다시 써도(구간 · 재시도) `merge` 라 하나로 모인다. 보상 유무 · 팬덤 유무와 무관하다. * * 무효 정정은 원장 행(`txIds`) 또는 제출 건(`submissionId`)으로 찾는다([voidCompletions]). diff --git a/app/firebase/functions/src/quest/quest.ts b/app/firebase/functions/src/quest/quest.ts index ba4083c..ca02366 100644 --- a/app/firebase/functions/src/quest/quest.ts +++ b/app/firebase/functions/src/quest/quest.ts @@ -1,4 +1,5 @@ import { onCall } from 'firebase-functions/v2/https'; +import { onDocumentWritten } from 'firebase-functions/v2/firestore'; import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore'; import { callerUid, num, optionalStr, str } from '../core/callable.js'; @@ -188,6 +189,40 @@ export const fnQuestProgress = onCall(async (req) => { return { questId, state }; }); +/** + * 운영 Quest 「참여 인원」(`quests/{id}.participantCount`) — 10/6 실기기 관찰 「참여 인원 0」. + * + * 운영 Quest(작성자 없음)의 참여 · 완료는 모두 진행 문서(`users/{uid}/questProgress/{questId}`)를 거친다 — + * 진행 세션 시작(`session`) · 출석 · 걸음 · Music Shake · 방문 · 스트리밍 · TIME ATTACK. 그래서 여기 한 곳에서 + * **사람 단위로 한 번** 센다. 표식 `quests/{questId}/participants/{uid}` 가 있으면 다시 세지 않는다(재전달 · 재참여 멱등). + * 사용자 생성 Quest 는 지급 · 제출 경로(`ugc.ts`)가 정원과 함께 따로 센다 — 여기서는 건너뛴다. + * 진행 문서가 퀘스트가 아닌 것(`ad` 한도 카운터)도 퀘스트 문서가 없으니 건너뛴다. + */ +export async function countParticipant( + uid: string, questId: string, after: { session?: unknown; count?: unknown }, +): Promise { + if (!after.session && !(typeof after.count === 'number' && after.count > 0)) return false; + const db = getFirestore(); + const questRef = db.doc(`quests/${questId}`); + const markRef = questRef.collection('participants').doc(uid); + return db.runTransaction(async (tx) => { + const [quest, mark] = await Promise.all([tx.get(questRef), tx.get(markRef)]); + if (!quest.exists || typeof quest.get('authorUid') === 'string' || mark.exists) return false; + tx.create(markRef, { uid, createdAt: FieldValue.serverTimestamp() }); + tx.update(questRef, { participantCount: FieldValue.increment(1) }); + return true; + }); +} + +export const fnQuestParticipants = onDocumentWritten( + { document: 'users/{uid}/questProgress/{questId}', retry: true }, + async (event) => { + const after = event.data?.after; + if (!after?.exists) return; + await countParticipant(event.params.uid, event.params.questId, after.data() ?? {}); + }, +); + /** `fn-quest-attend` — 출석. 한도는 `quests/{questId}.dailyLimit` 이 정한다. */ export const fnQuestAttend = onCall(async (req) => { const uid = callerUid(req); diff --git a/app/firebase/functions/src/quest/timeattack.ts b/app/firebase/functions/src/quest/timeattack.ts index df716ec..c351d4d 100644 --- a/app/firebase/functions/src/quest/timeattack.ts +++ b/app/firebase/functions/src/quest/timeattack.ts @@ -242,7 +242,8 @@ export async function joinTimeAttack(uid: string, questId: string, now = new Dat ...(counts ? { dayKey: key, count: done + 1 } : {}), completedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); - tx.update(quest.ref, { participantCount: FieldValue.increment(1) }); + // 운영 Quest 의 참여 인원은 진행 문서 트리거가 사람 단위로 센다([countParticipant]) — 여기서는 사용자 생성 Quest 만. + if (typeof quest.get('authorUid') === 'string') tx.update(quest.ref, { participantCount: FieldValue.increment(1) }); }, }); diff --git a/app/firebase/functions/src/quest/verify.ts b/app/firebase/functions/src/quest/verify.ts index 8ff94f0..c89ed73 100644 --- a/app/firebase/functions/src/quest/verify.ts +++ b/app/firebase/functions/src/quest/verify.ts @@ -183,8 +183,8 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => { uid, fandomId: fandomOfUser(user), axis: 'bpm', units: 1, activity: 'music_shake', sourceId: `music:${questId}:${key}:${state.count + 1}`, occurredAt: at, refPath: logRef.path, }, user); - // 검증 활동 — Music Shake 는 완주마다 1회. - markCompletion(tx, uid, `${questId}_${key}_${state.count + 1}`, { + // 검증 활동 — 같은 반복 Quest 는 같은 날 1회(C-128). 같은 날 두 번째 완주는 같은 표식에 모인다. + markCompletion(tx, uid, `${questId}_${key}`, { questId, txId: txIdOf(`quest:${uid}:${questId}:${key}:${state.count + 1}`), }); }, diff --git a/app/firebase/functions/test/campaign_settle.test.ts b/app/firebase/functions/test/campaign_settle.test.ts index 4f2cf81..98a25d5 100644 --- a/app/firebase/functions/test/campaign_settle.test.ts +++ b/app/firebase/functions/test/campaign_settle.test.ts @@ -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`)); diff --git a/app/firebase/functions/test/quest_e2e.test.ts b/app/firebase/functions/test/quest_e2e.test.ts index 82c7102..3f66098 100644 --- a/app/firebase/functions/test/quest_e2e.test.ts +++ b/app/firebase/functions/test/quest_e2e.test.ts @@ -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 () => { diff --git a/app/firebase/functions/test/quest_participants.test.ts b/app/firebase/functions/test/quest_participants.test.ts new file mode 100644 index 0000000..4ca62d5 --- /dev/null +++ b/app/firebase/functions/test/quest_participants.test.ts @@ -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) => + 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(); + } + }); +}); diff --git a/app/firebase/functions/test/timeattack.test.ts b/app/firebase/functions/test/timeattack.test.ts index b70fe0d..75525bc 100644 --- a/app/firebase/functions/test/timeattack.test.ts +++ b/app/firebase/functions/test/timeattack.test.ts @@ -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 () => { diff --git a/app/test/uxfx_round2_test.dart b/app/test/uxfx_round2_test.dart index feb0e39..8c01400 100644 --- a/app/test/uxfx_round2_test.dart +++ b/app/test/uxfx_round2_test.dart @@ -75,10 +75,11 @@ void main() { expect(shouldShowInApp(n('eclipse'), n('briefing')), isTrue); expect(shouldShowInApp(n('evolution', 'x'), n('quest', 'y')), isTrue); }); - test('위기 → 공동행동 → 성취 순', () { + test('위기 → 공동행동 → 성취 → 결과 순', () { expect(n('eclipse').tier, PushTier.crisis); expect(n('timeattack').tier, PushTier.collective); expect(n('evolution').tier, PushTier.achievement); + expect(n('campaign').tier, PushTier.result); expect(n('briefing').tier, PushTier.other); }); });