253 lines
10 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|