Files
fanuzu/app/test/monetization_test.dart
T

253 lines
10 KiB
Dart

import 'dart:async';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/monetization/store.dart';
import 'package:fancake/features/home/home_data.dart' show ItemProduct;
import 'package:fancake/features/home/home_repository_firestore.dart' show kstDayKey;
import 'package:fancake/features/home/widgets/item_sheet.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/my_screen.dart';
import 'package:fancake/features/my/my_repository_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
/// 결제 경계 — 별첨 1 ⑥ 「영수증 서버검증 통과」 · 정책 원장 B-02 · B-72.
///
/// 스토어와 서버는 대역이다. 보는 것은 두 가지다 — **서버가 받아들인 뒤에만 스토어에
/// 구매 완료를 알리는가**, 그리고 MY 결제 내역이 서버 문서를 화면 상태로 옳게 옮기는가.
const _uid = 'u-pay';
class _Backend extends Backend {
_Backend(FakeFirebaseFirestore db, {this.failWith})
: super(
firestore: db,
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
);
final BackendException? failWith;
final calls = <Map<String, dynamic>>[];
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> payload = const {},
]) async {
calls.add({'name': name, ...payload});
if (failWith case final error?) throw error;
return const {'granted': 100};
}
}
class _Focused extends SampleMyRepository {
bool claimed = false;
@override
Future<FocusStatus?> focus() async =>
FocusStatus(expiresAt: DateTime(2026, 10, 28), claimedToday: claimed);
@override
Future<int> claimFocusDaily() async {
claimed = true;
return 40;
}
}
class _FakeIap extends Fake implements InAppPurchase {
final updates = StreamController<List<PurchaseDetails>>();
final completed = <String>[];
@override
Stream<List<PurchaseDetails>> get purchaseStream => updates.stream;
@override
Future<void> completePurchase(PurchaseDetails purchase) async =>
completed.add(purchase.productID);
}
PurchaseDetails _purchase(PurchaseStatus status) => PurchaseDetails(
purchaseID: '2000000123',
productID: 'focus.monthly',
verificationData: PurchaseVerificationData(
localVerificationData: 'local',
serverVerificationData: 'receipt-1',
source: 'app_store',
),
transactionDate: '0',
status: status,
)..pendingCompletePurchase = true;
Future<(_FakeIap, _Backend, List<String>)> _run(
PurchaseStatus status, {
BackendException? failWith,
}) async {
final iap = _FakeIap();
final backend = _Backend(FakeFirebaseFirestore(), failWith: failWith);
final store = Store(backend, iap: iap);
final messages = <String>[];
store.messages.listen(messages.add);
store.start();
iap.updates.add([_purchase(status)]);
await pumpEventQueue();
await store.dispose();
return (iap, backend, messages);
}
void main() {
group('Store — 검증 뒤에만 구매 완료', () {
test('서버가 받아들이면 영수증을 보낸 뒤 구매를 완료한다', () async {
final (iap, backend, messages) = await _run(PurchaseStatus.purchased);
expect(backend.calls.single['name'], 'fnIapVerify');
expect(backend.calls.single['receipt'], 'receipt-1');
expect(backend.calls.single['productId'], 'focus.monthly');
// iOS 영수증에서 이 거래를 고르라고 거래 ID 를 함께 보낸다.
expect(backend.calls.single['transactionId'], '2000000123');
expect(iap.completed, ['focus.monthly']);
expect(messages, ['구매가 반영되었습니다.']);
});
test('서버가 거부하면 완료하지 않고 사유를 알린다', () async {
final (iap, _, messages) = await _run(
PurchaseStatus.purchased,
failWith: const BackendException('E-PAY-02', '결제 검증 설정이 아직 준비되지 않았습니다.'),
);
// 완료하지 않은 구매는 다음 실행에서 다시 검증된다(Android 는 끝내 미확인이면 자동 환불).
expect(iap.completed, isEmpty);
expect(messages, ['결제 검증 설정이 아직 준비되지 않았습니다.']);
});
test('재시도해도 같은 거부(E-PAY-01)는 거래를 닫고 한 번만 알린다(Android 외)', () async {
final (iap, _, messages) = await _run(
PurchaseStatus.purchased,
failWith: const BackendException('E-PAY-01', '판매하지 않는 상품입니다.'),
);
// 테스트 호스트는 Android 가 아니다. Android 는 확인하지 않고 두어 스토어 자동 환불을 받는다.
expect(iap.completed, ['focus.monthly']);
expect(messages, ['판매하지 않는 상품입니다.']);
});
test('승인 대기(pending)는 서버에 보내지 않고 기다린다', () async {
final (iap, backend, messages) = await _run(PurchaseStatus.pending);
expect(backend.calls, isEmpty);
expect(iap.completed, isEmpty);
expect(messages.single, contains('승인'));
});
test('복원 재전송은 조용히 검증만 한다', () async {
final (iap, backend, messages) = await _run(PurchaseStatus.restored);
expect(backend.calls, hasLength(1));
expect(iap.completed, ['focus.monthly']);
expect(messages, isEmpty);
});
});
group('Focus 일일 보상 — B-04', () {
test('만료 전 구독만 상태를 돌려주고 오늘 받았는지 서버 기록으로 판단한다', () async {
final db = FakeFirebaseFirestore();
final repo = FirestoreMyRepository(_Backend(db));
expect(await repo.focus(), isNull);
await db.doc('users/$_uid/subscription/current').set({
'state': 'active', 'expiresAt': DateTime.now().add(const Duration(days: 3)),
'focusClaimedDay': kstDayKey(),
});
expect((await repo.focus())!.claimedToday, isTrue);
await db.doc('users/$_uid/subscription/current').update({
'expiresAt': DateTime.now().subtract(const Duration(minutes: 1)),
});
expect(await repo.focus(), isNull);
});
testWidgets('MY 에 구독 카드가 뜨고 받으면 「오늘 받음」으로 잠긴다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: MyScreen(repository: _Focused())));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '오늘 보상 받기'));
await tester.pumpAndSettle();
expect(find.textContaining('+40 기여 POP'), findsOneWidget);
expect(find.widgetWithText(FilledButton, '오늘 받음'), findsOneWidget);
});
testWidgets('구독하지 않으면 카드를 그리지 않는다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: MyScreen(repository: SampleMyRepository())));
await tester.pumpAndSettle();
expect(find.text('광고 없는 집중모드 이용 중'), findsNothing);
});
});
testWidgets('소속 행성이 없으면 Hero Gift 결제 창을 열기 전에 막는다', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showItemSheet(
context,
items: const [
ItemProduct(
productId: 'herogift.basic', name: '베이직 드랍', description: '',
dropPop: 500, buyerPop: 500, priceWon: 1200, owned: 0,
),
],
purchase: true,
),
child: const Text('open'),
),
),
),
));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.text('베이직 드랍'));
await tester.pump();
expect(find.text('행성에 가입한 뒤 영웅의 선물을 보낼 수 있어요.'), findsOneWidget);
});
group('MY 결제 내역 — users/{uid}/purchases', () {
test('최신순으로 읽고 서버 상태를 화면 다섯 상태로 옮긴다', () async {
final db = FakeFirebaseFirestore();
await db.doc('users/$_uid/purchases/android_a').set({
'itemName': '베이직 드랍', 'priceAmount': 1200, 'method': 'Google Play',
'state': 'granted', 'purchasedAt': DateTime(2026, 9, 1),
});
await db.doc('users/$_uid/purchases/android_b').set({
'productId': 'focus.monthly', 'state': 'clawback-pending',
'purchasedAt': DateTime(2026, 9, 20),
});
final repo = FirestoreMyRepository(_Backend(db));
final rows = await repo.payments();
expect(rows.map((p) => p.id), ['android_b', 'android_a']);
expect(rows.last.itemName, '베이직 드랍');
expect(rows.last.price, 1200);
expect(rows.last.state, PaymentState.paid);
// 이름이 없으면 상품 ID, 회수 대기는 스토어 환불이 끝난 건이다.
expect(rows.first.itemName, 'focus.monthly');
expect(rows.first.state, PaymentState.refunded);
});
test('환불 요청은 상태 · 사유 · 시각만 쓴다', () async {
final db = FakeFirebaseFirestore();
await db.doc('users/$_uid/purchases/android_a').set({
'state': 'granted', 'amountPop': 100, 'purchasedAt': DateTime(2026, 9, 1),
});
final repo = FirestoreMyRepository(_Backend(db));
await repo.requestRefund('android_a', '실수로 결제했습니다');
final doc = (await db.doc('users/$_uid/purchases/android_a').get()).data()!;
expect(doc['state'], 'refund-requested');
expect(doc['refundReason'], '실수로 결제했습니다');
expect(doc['amountPop'], 100);
expect((await repo.payment('android_a')).state, PaymentState.refundRequested);
});
});
}