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 = >[]; @override Future> call( String name, [ Map 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 focus() async => FocusStatus(expiresAt: DateTime(2026, 10, 28), claimedToday: claimed); @override Future claimFocusDaily() async { claimed = true; return 40; } } class _FakeIap extends Fake implements InAppPurchase { final updates = StreamController>(); final completed = []; @override Stream> get purchaseStream => updates.stream; @override Future 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)> _run( PurchaseStatus status, { BackendException? failWith, }) async { final iap = _FakeIap(); final backend = _Backend(FakeFirebaseFirestore(), failWith: failWith); final store = Store(backend, iap: iap); final messages = []; 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); }); }); }