524 lines
27 KiB
TypeScript
524 lines
27 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
|
|
|
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
|
import { grantPop, refundPop } from '../src/ledger/ledger.js';
|
|
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,
|
|
notifySuccess, retryPendingRefunds, settleDueCampaigns, submitCampaignReport,
|
|
} from '../src/campaign/settle.js';
|
|
|
|
/**
|
|
* 캠페인 개설 · 정산 · 자동 환불 · 결과보고 — 별첨 1 ⑤ 판정 기준
|
|
* 「성공/실패 분기 정확 · 자동환불 무결성(부분 실패 방치·중복 환불 0건) · 결과보고 게시 확인」.
|
|
*
|
|
* 기간 단축은 `endsAt` 을 과거로 옮기고 정산 코어를 직접 부르는 것으로 한다 — 10분 스케줄을
|
|
* 기다리지 않고 같은 코드가 도는지를 본다.
|
|
*/
|
|
|
|
const CREATOR = 'test-cmp-creator';
|
|
|
|
/** 검증 활동(Quest 완료) 표식 n건 — Campaign 생성자격 5회(A안). */
|
|
async function completions(uid: string, n: number) {
|
|
for (let i = 0; i < n; i++) await getFirestore().doc(`users/${uid}/verifiedCompletions/q${i}_2026-09-0${i + 1}`).set({ questId: `q${i}` });
|
|
}
|
|
const A = 'test-cmp-donor-a';
|
|
const B = 'test-cmp-donor-b';
|
|
const db = () => getFirestore();
|
|
|
|
beforeAll(() => { bootAdmin(); });
|
|
afterAll(async () => { await shutdownAdmin(); });
|
|
beforeEach(async () => {
|
|
for (const uid of [CREATOR, A, B]) {
|
|
await db().recursiveDelete(db().collection(`users/${uid}/notifications`));
|
|
await wipeUser(uid);
|
|
}
|
|
// 정산·재시도는 컬렉션 전체를 훑으므로 다른 회차가 남긴 캠페인을 지운다.
|
|
await db().recursiveDelete(db().collection('campaigns'));
|
|
await db().recursiveDelete(db().collection('adminActionLogs'));
|
|
});
|
|
|
|
async function code(run: () => Promise<unknown>): Promise<string> {
|
|
try {
|
|
await run();
|
|
} catch (error) {
|
|
const e = error as { details?: { code?: string }; code?: string };
|
|
return e.details?.code ?? e.code ?? String(error);
|
|
}
|
|
throw new Error('실패해야 하는 호출이 성공했습니다.');
|
|
}
|
|
|
|
/** 모금 중 캠페인과 후원들을 만들고 마감을 지난 시각으로 옮긴다(기간 단축). */
|
|
async function fundedCampaign(id: string, target: number, gifts: Array<[string, number]>) {
|
|
await db().doc(`campaigns/${id}`).set({
|
|
creatorUid: CREATOR, title: `캠페인 ${id}`, status: 'funding',
|
|
targetPop: target, raisedPop: 0, donorCount: 0, refundState: 'none',
|
|
endsAt: Timestamp.fromMillis(Date.now() + 60_000),
|
|
});
|
|
for (const [uid, amount] of gifts) {
|
|
await grantPop({
|
|
uid, popType: 'support', amount: 1_000,
|
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:${id}:${uid}`,
|
|
});
|
|
await fundCampaign({ uid, campaignId: id, clientSeq: '1', amount });
|
|
}
|
|
await db().doc(`campaigns/${id}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
|
|
}
|
|
|
|
const supportOf = async (uid: string) =>
|
|
(await db().doc(`users/${uid}/wallet/main`).get()).get('supportBalance') as number;
|
|
|
|
const refundRows = async (uid: string) => (await db()
|
|
.collection(`users/${uid}/popTransactions`)
|
|
.where('reasonCode', '==', 'CAMPAIGN_REFUND').get()).size;
|
|
|
|
const feeRefundRows = async (uid: string) => (await db()
|
|
.collection(`users/${uid}/popTransactions`)
|
|
.where('reasonCode', '==', 'CAMPAIGN_FEE_REFUND').get()).size;
|
|
|
|
const notes = async (uid: string) =>
|
|
(await db().collection(`users/${uid}/notifications`).get()).docs.map((d) => d.id);
|
|
|
|
describe('개설 — fn-campaign-create', () => {
|
|
beforeEach(async () => {
|
|
await db().doc(`users/${CREATOR}`).set({
|
|
displayName: '김실명', nickname: '개설자', fandomId: 'f-cmp', status: 'active',
|
|
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
|
|
});
|
|
await completions(CREATOR, 5);
|
|
});
|
|
const draft = { uid: CREATOR, title: '지하철 광고', body: '5주년', targetPop: 500, days: 7 };
|
|
|
|
it('등록비 100 POP(일반)을 빼고 review 로 올린다 · 같은 요청 재시도는 한 건', async () => {
|
|
await grantPop({ uid: CREATOR, popType: 'activity', amount: 150, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:fee' });
|
|
|
|
const first = await createCampaign({ ...draft, clientSeq: 's1' });
|
|
const again = await createCampaign({ ...draft, clientSeq: 's1' });
|
|
expect(again.campaignId).toBe(first.campaignId);
|
|
|
|
const doc = await db().doc(`campaigns/${first.campaignId}`).get();
|
|
expect(doc.get('status')).toBe('review');
|
|
expect(doc.get('fandomId')).toBe('f-cmp');
|
|
expect(doc.get('targetPop')).toBe(500);
|
|
// 개설자 이름은 공개 닉네임 — 공급자 실명(displayName)을 싣지 않는다(M-7).
|
|
expect(doc.get('creatorName')).toBe('개설자');
|
|
expect((await db().doc(`users/${CREATOR}/wallet/main`).get()).get('activityBalance')).toBe(50);
|
|
const fees = await db().collection(`users/${CREATOR}/popTransactions`)
|
|
.where('reasonCode', '==', 'CAMPAIGN_FEE').get();
|
|
expect(fees.size).toBe(1);
|
|
|
|
// 진행 중 1건 보유 시 2건째 차단(B-56).
|
|
expect(await code(() => createCampaign({ ...draft, clientSeq: 's2' }))).toBe('E-PERM-01');
|
|
|
|
// 운영 승인으로 모금이 열린다.
|
|
await fnCampaignAdminReview.run({
|
|
data: { campaignId: first.campaignId, requestId: 'r1', approve: true, memo: 'ok' },
|
|
auth: { uid: 'ops-user', token: { role: 'ops' } },
|
|
} as never);
|
|
expect((await db().doc(`campaigns/${first.campaignId}`).get()).get('status')).toBe('funding');
|
|
});
|
|
|
|
it('검증 활동(Quest 완료) 5회 미만은 E-CMP-04 · 남은 횟수를 싣는다(Campaign 개설자격 A안)', async () => {
|
|
await grantPop({ uid: CREATOR, popType: 'activity', amount: 150, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:elig' });
|
|
await db().recursiveDelete(db().collection(`users/${CREATOR}/verifiedCompletions`));
|
|
await completions(CREATOR, 3);
|
|
const err = await createCampaign({ ...draft, clientSeq: 'e1' }).then(() => null, (e: unknown) => e);
|
|
expect((err as { details?: Record<string, unknown> }).details).toEqual({ code: 'E-CMP-04', done: 3, need: 5 });
|
|
expect((await db().collection('campaigns').get()).size).toBe(0);
|
|
// 기준 횟수는 운영값이다.
|
|
await db().doc('config/campaign').set({ minVerifiedQuests: 3 }, { merge: true });
|
|
try {
|
|
expect((await createCampaign({ ...draft, clientSeq: 'e2' })).status).toBe('review');
|
|
} finally {
|
|
await db().doc('config/campaign').update({ minVerifiedQuests: FieldValue.delete() });
|
|
}
|
|
});
|
|
|
|
it('잔액 부족 · 가입 7일 미만은 거부하고 캠페인을 남기지 않는다', async () => {
|
|
expect(await code(() => createCampaign({ ...draft, clientSeq: 'poor' }))).toBe('E-POP-01');
|
|
await db().doc(`users/${CREATOR}`).update({ createdAt: Timestamp.now() });
|
|
expect(await code(() => createCampaign({ ...draft, clientSeq: 'new' }))).toBe('E-PERM-01');
|
|
expect((await db().collection('campaigns').get()).size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('등록비 분할 · 반환 (B-57 · B-58)', () => {
|
|
beforeEach(async () => {
|
|
await db().doc(`users/${CREATOR}`).set({
|
|
displayName: '개설자', fandomId: 'f-cmp', status: 'active',
|
|
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
|
|
});
|
|
await completions(CREATOR, 5);
|
|
await grantPop({ uid: CREATOR, popType: 'activity', amount: 40, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:fee:a' });
|
|
await grantPop({ uid: CREATOR, popType: 'support', amount: 500, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'g:fee:s' });
|
|
});
|
|
const draft = { uid: CREATOR, title: '광고', body: '본문', targetPop: 500, days: 7 };
|
|
const walletOf = async () => {
|
|
const w = await db().doc(`users/${CREATOR}/wallet/main`).get();
|
|
return [w.get('activityBalance'), w.get('supportBalance')];
|
|
};
|
|
const review = (campaignId: string, requestId: string, approve: boolean) => fnCampaignAdminReview.run({
|
|
data: { campaignId, requestId, approve, memo: 'm' },
|
|
auth: { uid: 'ops-user', token: { role: 'ops' } },
|
|
} as never);
|
|
|
|
it('일반 40 + 기여 60 으로 나눠 내고, 반려되면 각 타입으로 한 번만 돌려받는다', async () => {
|
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'split' });
|
|
expect(await walletOf()).toEqual([0, 440]);
|
|
const fee = await db().doc(`campaigns/${campaignId}`).get();
|
|
expect((fee.get('feeTxIds') as string[]).length).toBe(2);
|
|
|
|
await review(campaignId, 'r-reject', false);
|
|
await review(campaignId, 'r-reject', false);
|
|
const doc = await db().doc(`campaigns/${campaignId}`).get();
|
|
expect(doc.get('status')).toBe('rejected');
|
|
expect(doc.get('feeRefund')).toBe('done');
|
|
expect(await walletOf()).toEqual([40, 500]);
|
|
expect(await feeRefundRows(CREATOR)).toBe(2);
|
|
expect(await refundRows(CREATOR)).toBe(0);
|
|
});
|
|
|
|
it('반려 전이 뒤 반환 전에 끊겨도 — 새 요청 ID 의 재시도와 스케줄이 마저 돌려준다', async () => {
|
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'crash' });
|
|
// 트랜잭션은 섰고 반환 호출 전에 죽은 상태.
|
|
await db().doc(`campaigns/${campaignId}`).update({ status: 'rejected', feeRefund: 'due' });
|
|
expect(await code(() => review(campaignId, 'r-new', false))).toBe('E-CMP-03');
|
|
expect(await walletOf()).toEqual([40, 500]);
|
|
expect((await db().doc(`campaigns/${campaignId}`).get()).get('feeRefund')).toBe('done');
|
|
});
|
|
|
|
it('스케줄(retryFeeRefunds)이 남은 반환을 한 번만 돌린다 · 옛 feeTxId 한 건도', async () => {
|
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'sweep' });
|
|
const ref = db().doc(`campaigns/${campaignId}`);
|
|
const [general] = (await ref.get()).get('feeTxIds') as string[];
|
|
// 분할 전 데이터 모양 — 단일 feeTxId(일반 몫만 남긴다).
|
|
await ref.update({ status: 'rejected', feeRefund: 'due', feeTxIds: FieldValue.delete(), feeTxId: general });
|
|
expect(await retryFeeRefunds()).toBe(1);
|
|
expect(await retryFeeRefunds()).toBe(0);
|
|
expect(await walletOf()).toEqual([40, 440]);
|
|
expect(await feeRefundRows(CREATOR)).toBe(1);
|
|
});
|
|
|
|
it('승인과 철회가 겹치면 한쪽 판정만 선다', async () => {
|
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'race' });
|
|
const [approved] = await Promise.allSettled([
|
|
review(campaignId, 'r-race', true),
|
|
withdrawCampaign(CREATOR, campaignId),
|
|
]);
|
|
const doc = await db().doc(`campaigns/${campaignId}`).get();
|
|
if (approved.status === 'fulfilled') {
|
|
// 승인이 먼저 — 철회는 공개 후 첫 후원 전 철회라 비환불.
|
|
expect(['funding', 'withdrawn']).toContain(doc.get('status'));
|
|
expect(await feeRefundRows(CREATOR)).toBe(0);
|
|
} else {
|
|
expect(doc.get('status')).toBe('withdrawn');
|
|
expect(await feeRefundRows(CREATOR)).toBe(2);
|
|
expect((await db().collection(`campaigns/${campaignId}/reviews`).get()).size).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('공개 전 철회는 등록비를 돌려주고 재시도해도 한 번뿐이다', async () => {
|
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'wd' });
|
|
expect((await withdrawCampaign(CREATOR, campaignId)).status).toBe('withdrawn');
|
|
await withdrawCampaign(CREATOR, campaignId);
|
|
expect(await walletOf()).toEqual([40, 500]);
|
|
expect(await feeRefundRows(CREATOR)).toBe(2);
|
|
// 철회한 캠페인은 진행 중 1건에 세지 않는다.
|
|
await createCampaign({ ...draft, clientSeq: 'wd2' });
|
|
});
|
|
|
|
it('공개 후 첫 후원 전 철회는 되지만 등록비는 비환불 · 첫 후원 뒤에는 E-PERM-01', async () => {
|
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'pub' });
|
|
await review(campaignId, 'r-ok', true);
|
|
expect(await code(() => withdrawCampaign('someone-else', campaignId))).toBe('E-PERM-01');
|
|
await withdrawCampaign(CREATOR, campaignId);
|
|
expect((await db().doc(`campaigns/${campaignId}`).get()).get('status')).toBe('withdrawn');
|
|
expect(await walletOf()).toEqual([0, 440]);
|
|
expect(await feeRefundRows(CREATOR)).toBe(0);
|
|
|
|
await fundedCampaign('donated', 1_000, [[A, 10]]);
|
|
await db().doc('campaigns/donated').update({ endsAt: Timestamp.fromMillis(Date.now() + 60_000) });
|
|
expect(await code(() => withdrawCampaign(CREATOR, 'donated'))).toBe('E-PERM-01');
|
|
});
|
|
});
|
|
|
|
describe('회사귀책 취소 — fn-campaign-admin-cancel (B-58)', () => {
|
|
it('등록비와 후원 전액을 한 번만 돌려주고 개설자 · 후원자에게 알린다', async () => {
|
|
await db().doc(`users/${CREATOR}`).set({
|
|
displayName: '개설자', fandomId: 'f-cmp', status: 'active',
|
|
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
|
|
});
|
|
await completions(CREATOR, 5);
|
|
await grantPop({ uid: CREATOR, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cancel' });
|
|
const { campaignId } = await createCampaign({
|
|
uid: CREATOR, title: '광고', body: '본문', targetPop: 1_000, days: 7, clientSeq: 'cancel',
|
|
});
|
|
await fnCampaignAdminReview.run({
|
|
data: { campaignId, requestId: 'ok', approve: true, memo: 'm' },
|
|
auth: { uid: 'ops-user', token: { role: 'ops' } },
|
|
} as never);
|
|
for (const [uid, amount] of [[A, 30], [B, 50]] as const) {
|
|
await grantPop({ uid, popType: 'support', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:cancel:${uid}` });
|
|
await fundCampaign({ uid, campaignId, clientSeq: '1', amount });
|
|
}
|
|
|
|
const first = await cancelCampaign(campaignId, 'ops-user', '운영 오류');
|
|
expect(first.refund.refunded).toBe(2);
|
|
await cancelCampaign(campaignId, 'ops-user', '운영 오류');
|
|
|
|
const doc = await db().doc(`campaigns/${campaignId}`).get();
|
|
expect(doc.get('status')).toBe('cancelled');
|
|
expect(doc.get('refundState')).toBe('done');
|
|
expect(doc.get('feeRefund')).toBe('done');
|
|
expect(await supportOf(A)).toBe(100);
|
|
expect(await supportOf(B)).toBe(100);
|
|
expect(await refundRows(A)).toBe(1);
|
|
expect(await refundRows(B)).toBe(1);
|
|
expect(await feeRefundRows(CREATOR)).toBe(1);
|
|
expect((await db().doc(`users/${CREATOR}/wallet/main`).get()).get('activityBalance')).toBe(100);
|
|
expect(await notes(CREATOR)).toEqual([`campaignCancel_${campaignId}`]);
|
|
expect((await notes(A)).length).toBe(1);
|
|
// 환불 알림 링크가 취소 문서를 열도록 규칙이 보는 후원자 표식(backers/{uid}).
|
|
const backers = await db().collection(`campaigns/${campaignId}/backers`).get();
|
|
expect(backers.docs.map((d) => d.id).sort()).toEqual([A, B].sort());
|
|
|
|
// 성공 · 실패로 끝난 캠페인은 취소 대상이 아니다.
|
|
await db().doc('campaigns/done').set({ creatorUid: CREATOR, status: 'success' });
|
|
expect(await code(() => cancelCampaign('done', 'ops-user', 'x'))).toBe('E-CMP-03');
|
|
});
|
|
});
|
|
|
|
it('후원 문서에 공개 닉네임(donorName)을 싣는다 — 실시간 랭킹 이름(M-7), 닉네임 없으면 「팬」', async () => {
|
|
await db().doc(`users/${A}`).set({ nickname: '후원왕', displayName: '김실명' }, { merge: true });
|
|
await fundedCampaign('cmp-names', 1_000, [[A, 10], [B, 10]]);
|
|
const rows = (await db().collection('campaigns/cmp-names/donations').get()).docs.map((d) => d.data());
|
|
expect(rows.find((r) => r.uid === A)?.donorName).toBe('후원왕');
|
|
expect(rows.find((r) => r.uid === B)?.donorName).toBe('팬');
|
|
await db().recursiveDelete(db().doc('campaigns/cmp-names'));
|
|
});
|
|
|
|
describe('정산 분기', () => {
|
|
it('목표(targetPop) 달성은 success, 미달은 fail 이다', async () => {
|
|
await fundedCampaign('cmp-ok', 500, [[A, 300], [B, 200]]);
|
|
await fundedCampaign('cmp-short', 1_000, [[A, 100]]);
|
|
|
|
expect(await settleDueCampaigns()).toBe(2);
|
|
expect(await settleDueCampaigns()).toBe(0); // 두 번 판정하지 않는다.
|
|
|
|
const ok = await db().doc('campaigns/cmp-ok').get();
|
|
const short = await db().doc('campaigns/cmp-short').get();
|
|
expect(ok.get('status')).toBe('success');
|
|
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 () => {
|
|
await fundedCampaign('cmp-donors', 1_000, [[A, 100], [B, 50]]);
|
|
await db().doc('campaigns/cmp-donors').update({ endsAt: Timestamp.fromMillis(Date.now() + 60_000) });
|
|
await fundCampaign({ uid: A, campaignId: 'cmp-donors', clientSeq: '2', amount: 30 });
|
|
const snap = await db().doc('campaigns/cmp-donors').get();
|
|
expect(snap.get('donorCount')).toBe(2);
|
|
expect(snap.get('raisedPop')).toBe(180);
|
|
});
|
|
|
|
it('마감이 지난 캠페인은 정산 전이라도 후원을 받지 않는다', async () => {
|
|
await fundedCampaign('cmp-late', 1_000, [[A, 100]]);
|
|
expect(await code(() => fundCampaign({ uid: A, campaignId: 'cmp-late', clientSeq: '2', amount: 50 })))
|
|
.toBe('E-CMP-03');
|
|
expect((await db().doc('campaigns/cmp-late').get()).get('raisedPop')).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('자동 환불', () => {
|
|
it('실패 판정 뒤 관리자 호출 없이 전건을 돌려주고 알린다 · 재실행해도 중복 0', async () => {
|
|
await fundedCampaign('cmp-fail', 1_000, [[A, 300], [B, 200]]);
|
|
await settleDueCampaigns();
|
|
await retryPendingRefunds();
|
|
|
|
const campaign = await db().doc('campaigns/cmp-fail').get();
|
|
expect(campaign.get('refundState')).toBe('done');
|
|
expect(campaign.get('refundedCount')).toBe(2);
|
|
expect(campaign.get('lockedUntil')).toBeUndefined();
|
|
expect(await supportOf(A)).toBe(1_000);
|
|
expect(await supportOf(B)).toBe(1_000);
|
|
const donation = (await db().collection('campaigns/cmp-fail/donations').where('uid', '==', A).get()).docs[0]!;
|
|
expect(await notes(A)).toEqual([`campaignRefund_${donation.get('refundTxId')}`]);
|
|
|
|
expect(await retryPendingRefunds()).toBe(0);
|
|
await refundCampaign('cmp-fail', { manual: true });
|
|
expect(await refundRows(A)).toBe(1);
|
|
expect(await notes(A)).toHaveLength(1);
|
|
});
|
|
|
|
it('스케줄과 Admin 배치가 겹쳐도 후원 한 건당 한 번만 돌려준다', async () => {
|
|
await fundedCampaign('cmp-race', 1_000, [[A, 300], [B, 200]]);
|
|
await settleDueCampaigns();
|
|
|
|
const [auto, manual] = await Promise.all([
|
|
retryPendingRefunds(),
|
|
refundCampaign('cmp-race', { manual: true }),
|
|
]);
|
|
expect(auto + (manual.skipped ? 0 : 1)).toBeGreaterThanOrEqual(1);
|
|
expect(await refundRows(A)).toBe(1);
|
|
expect(await refundRows(B)).toBe(1);
|
|
expect(await supportOf(A)).toBe(1_000);
|
|
expect((await db().doc('campaigns/cmp-race').get()).get('refundState')).toBe('done');
|
|
});
|
|
|
|
it('잠금이 살아 있으면 건너뛴다', async () => {
|
|
await fundedCampaign('cmp-lock', 1_000, [[A, 300]]);
|
|
await settleDueCampaigns();
|
|
await db().doc('campaigns/cmp-lock').update({ lockedUntil: Timestamp.fromMillis(Date.now() + 60_000) });
|
|
expect((await refundCampaign('cmp-lock', { manual: true })).skipped).toBe('locked');
|
|
expect(await refundRows(A)).toBe(0);
|
|
});
|
|
|
|
it('다른 경로로 이미 돌려준 원거래는 환불 끝으로 보고, 알림이 빠졌으면 다시 보낸다', async () => {
|
|
await fundedCampaign('cmp-dup', 1_000, [[A, 300]]);
|
|
await settleDueCampaigns();
|
|
const donation = (await db().collection('campaigns/cmp-dup/donations').get()).docs[0]!;
|
|
await refundPop({
|
|
uid: A, originalTxId: donation.get('txId'),
|
|
idempotencyKey: 'manual-other-key', reasonCode: 'CAMPAIGN_REFUND',
|
|
});
|
|
|
|
await retryPendingRefunds();
|
|
expect((await db().doc('campaigns/cmp-dup').get()).get('refundState')).toBe('done');
|
|
expect(await refundRows(A)).toBe(1);
|
|
expect(await supportOf(A)).toBe(1_000);
|
|
expect(await notes(A)).toHaveLength(1);
|
|
|
|
// 환불 표시는 남고 알림 전에 죽은 경우 — 알림만 다시 간다.
|
|
await db().recursiveDelete(db().collection(`users/${A}/notifications`));
|
|
await donation.ref.update({ refundNotifiedAt: null });
|
|
await db().doc('campaigns/cmp-dup').update({ refundState: 'partial' });
|
|
await retryPendingRefunds();
|
|
expect(await notes(A)).toHaveLength(1);
|
|
expect(await refundRows(A)).toBe(1);
|
|
});
|
|
|
|
it('부분 실패는 partial 로 남아 자동 재시도하고, 상한에서 Admin 큐로 간다', async () => {
|
|
await fundedCampaign('cmp-partial', 1_000, [[A, 300]]);
|
|
await db().doc('campaigns/cmp-partial/donations/broken').set({ uid: B, amount: 50 });
|
|
await settleDueCampaigns();
|
|
|
|
await retryPendingRefunds();
|
|
let campaign = await db().doc('campaigns/cmp-partial').get();
|
|
expect(campaign.get('refundState')).toBe('partial');
|
|
expect(campaign.get('refundFailedCount')).toBe(1);
|
|
expect(campaign.get('refundAttempts')).toBe(1);
|
|
expect(await supportOf(A)).toBe(1_000);
|
|
expect((await db().collection('adminActionLogs').get()).size).toBe(0);
|
|
|
|
for (let i = 1; i < MAX_AUTO_REFUND_ATTEMPTS; i++) await retryPendingRefunds();
|
|
campaign = await db().doc('campaigns/cmp-partial').get();
|
|
expect(campaign.get('refundAttempts')).toBe(MAX_AUTO_REFUND_ATTEMPTS);
|
|
expect(campaign.get('refundQueuedAt')).toBeTruthy();
|
|
const queue = await db().collection('adminActionLogs').get();
|
|
expect(queue.size).toBe(1);
|
|
expect(queue.docs[0]?.get('targetPath')).toBe('campaigns/cmp-partial');
|
|
|
|
expect(await retryPendingRefunds()).toBe(0);
|
|
expect(await refundRows(A)).toBe(1);
|
|
});
|
|
|
|
it('옛 정산의 failed 는 targetPop 으로 다시 판정한다 — 목표를 채웠으면 돌려주지 않는다', async () => {
|
|
// 예전 정산은 goalPop 만 읽어 목표를 채운 캠페인도 failed 로 적었다(refundAttempts 없음).
|
|
await fundedCampaign('cmp-legacy-met', 500, [[A, 500]]);
|
|
await fundedCampaign('cmp-legacy-short', 1_000, [[B, 200]]);
|
|
for (const id of ['cmp-legacy-met', 'cmp-legacy-short']) {
|
|
await db().doc(`campaigns/${id}`).update({ status: 'failed', refundState: 'pending' });
|
|
}
|
|
|
|
await retryPendingRefunds();
|
|
const met = await db().doc('campaigns/cmp-legacy-met').get();
|
|
expect(met.get('status')).toBe('failed');
|
|
expect(met.get('refundState')).toBe('pending');
|
|
expect(await refundRows(A)).toBe(0);
|
|
expect(await supportOf(A)).toBe(500);
|
|
expect((await refundCampaign('cmp-legacy-met', { manual: true })).skipped).toBe('legacy-goal-met');
|
|
|
|
const short = await db().doc('campaigns/cmp-legacy-short').get();
|
|
expect(short.get('status')).toBe('fail');
|
|
expect(short.get('refundState')).toBe('done');
|
|
expect(await supportOf(B)).toBe(1_000);
|
|
});
|
|
});
|
|
|
|
describe('결과보고 — 운영 전용 (B-35)', () => {
|
|
const file = (id: string, name: string) => `campaignReports/${id}/${name}`;
|
|
const uploaded = async () => true;
|
|
const report = (id: string) => ({
|
|
uid: 'ops-user', campaignId: id, body: '광고 집행 완료',
|
|
photoPaths: [file(id, 'p1.png')],
|
|
receiptPaths: [file(id, 'r1.png')],
|
|
budget: [{ label: '광고 집행', amount: 450 }, { label: '제작비', amount: 50 }],
|
|
});
|
|
|
|
it('성공 캠페인에 올리면 report 로 바뀌고 후원자에게 알린다 · 재호출은 중복이고 빠진 알림만 보낸다', async () => {
|
|
await fundedCampaign('cmp-rep', 500, [[A, 500]]);
|
|
await settleDueCampaigns();
|
|
|
|
expect(await submitCampaignReport(report('cmp-rep'), uploaded)).toEqual({ duplicate: false });
|
|
expect((await db().doc('campaigns/cmp-rep').get()).get('status')).toBe('report');
|
|
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', 'campaignSuccess_cmp-rep']);
|
|
|
|
// 게시 뒤 알림 전에 죽은 경우.
|
|
await db().recursiveDelete(db().collection(`users/${A}/notifications`));
|
|
await doc.ref.update({ notifiedAt: null });
|
|
expect(await submitCampaignReport(report('cmp-rep'), uploaded)).toEqual({ duplicate: true });
|
|
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']);
|
|
});
|
|
|
|
it('운영이 아니면 거부한다(개설자 포함)', async () => {
|
|
await fundedCampaign('cmp-rep-role', 500, [[A, 500]]);
|
|
await settleDueCampaigns();
|
|
expect(await code(() => fnCampaignReportSubmit.run({
|
|
data: { ...report('cmp-rep-role') }, auth: { uid: CREATOR, token: {} },
|
|
} as never))).toBe('E-PERM-01');
|
|
});
|
|
|
|
it('실패 캠페인 · 경로 우회 · URL · 올라가지 않은 파일 · 빈 영수증·예산은 거부한다', async () => {
|
|
await fundedCampaign('cmp-rep2', 500, [[A, 500]]);
|
|
await fundedCampaign('cmp-rep-fail', 1_000, [[B, 100]]);
|
|
await settleDueCampaigns();
|
|
const r = report('cmp-rep2');
|
|
|
|
expect(await code(() => submitCampaignReport(report('cmp-rep-fail'), uploaded))).toBe('E-CMP-03');
|
|
for (const bad of [
|
|
'campaignReports/cmp-rep2/../other/p.png',
|
|
'campaignReports/cmp-rep2/sub/p.png',
|
|
'campaignReports/other/p.png',
|
|
'users/ops-user/campaignReports/cmp-rep2/p.png',
|
|
'https://firebasestorage.googleapis.com/v0/b/x/o/campaignReports%2Fcmp-rep2%2Fp.png',
|
|
'campaignReports/cmp-rep2/..',
|
|
]) {
|
|
expect(await code(() => submitCampaignReport({ ...r, photoPaths: [bad] }, uploaded))).toBe('invalid-argument');
|
|
}
|
|
expect(await code(() => submitCampaignReport(r, async () => false))).toBe('invalid-argument');
|
|
expect(await code(() => submitCampaignReport({ ...r, receiptPaths: [] }, uploaded))).toBe('invalid-argument');
|
|
expect(await code(() => submitCampaignReport({ ...r, budget: [] }, uploaded))).toBe('invalid-argument');
|
|
expect(await code(() => submitCampaignReport(
|
|
{ ...r, budget: [{ label: '광고', amount: -1 }] }, uploaded,
|
|
))).toBe('invalid-argument');
|
|
expect((await db().doc('campaigns/cmp-rep2').get()).get('status')).toBe('success');
|
|
});
|
|
});
|