import 'package:fancake/core/data/backend.dart'; import 'package:fancake/core/data/repositories.dart'; import 'package:fancake/core/firebase/firebase_boot.dart'; import 'package:fancake/features/auth/auth.dart'; import 'package:fancake/features/auth/auth_repository.dart'; import 'package:fancake/features/quest/quest_data.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; /// 퀘스트 참여 실왕복 — 별첨 1 ② 「클라이언트 단독 지급 0건」과 ③ 「서버검증 경유」. /// /// **앱에서 Cloud Functions 를 직접 부른다.** 앞선 `pop_loop_emulator_test.dart` 는 /// 서버가 쓴 값을 앱이 읽는 쪽만 봤고, 여기서는 **앱 → Callable → 원장 → 앱** 한 바퀴를 /// 돈다. 로그인도 실제 Auth 에뮬레이터를 지난다. /// /// 실행 — 에뮬레이터 세 개를 함께 띄운다. **저장소 안의 firebase-tools** 를 쓴다. /// /// ```bash /// adb reverse tcp:8080 tcp:8080 && adb reverse tcp:9099 tcp:9099 \ /// && adb reverse tcp:5001 tcp:5001 /// cd firebase /// JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \ /// test/node_modules/.bin/firebase emulators:exec \ /// --only auth,firestore,functions --project demo-fanuzu \ /// "bash ../tool/emulator_quest_roundtrip.sh" /// ``` /// /// 시드(퀘스트 정의·운영값)는 그 스크립트가 에뮬레이터 REST 로 넣는다 — 보안 규칙이 /// `quests` 쓰기를 마스터로 막아 두어 앱이 만들 수 없다(그게 맞다). void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); /// 시드 스크립트가 넣는 퀘스트 문서 ID. const questId = 'daily-checkin'; late AuthRepository auth; late Repositories repositories; setUpAll(() async { await FirebaseBoot.init(); auth = AuthRepository(); // 매 회차 새 계정이다. `fn-auth-oncreate` 트리거가 계정·지갑 문서를 만드는 것까지 // 이 왕복에 들어간다 — 앱은 `users/{uid}` 를 직접 만들지 못한다(보안 규칙). final email = 'quest+${DateTime.now().microsecondsSinceEpoch}@fanuzu.co.kr'; final outcome = await auth.signInWithEmail( email: email, password: 'password12', signUp: true, ); expect(outcome, isA(), reason: 'Auth 에뮬레이터에 붙지 못했습니다'); repositories = Repositories(live: true); }); testWidgets('에뮬레이터를 물고 로그인까지 지난다', (tester) async { expect(FirebaseBoot.useEmulator, isTrue, reason: '--dart-define=FANUZU_EMULATOR_HOST 를 주고 실행하십시오'); expect(FirebaseBoot.isLive, isTrue, reason: FirebaseBoot.reason); expect(auth.isSignedIn, isTrue); }); testWidgets('출석 참여 한 번이 서버 지급과 원장까지 간다', (tester) async { final quests = await repositories.quest.load(); final attend = quests.firstWhere((card) => card.id == questId); expect(attend.type, QuestType.attend); expect(attend.stage, QuestStage.notStarted); // 앱이 부르는 것은 Firestore 쓰기가 아니라 Callable 이다. final reward = await repositories.quest.verify(attend); expect(reward.rewardedPop, 10); expect(reward.balanceAfter, 10); expect(reward.txId, isNotNull); // 원장 한 행 — 앱이 쓴 것이 아니라 서버가 쓴 것이다. final uid = repositories.backend.requireUid; final ledger = await repositories.backend.db.collection('users/$uid/popTransactions').get(); expect(ledger.size, 1); expect(ledger.docs.single.data()['reasonCode'], 'QUEST_ATTEND'); expect(ledger.docs.single.data()['serverVerified'], isTrue); }); testWidgets('보유 POP 과 퀘스트 상태가 서버 값으로 다시 그려진다', (tester) async { // HOME 은 지갑을 읽는다. 출석 보상 10 POP 이 그대로 보인다. final home = await repositories.home.load(); expect(home.popBalance, 10); // 목록을 다시 읽으면 서버 진행 문서로 「보상완료」다 — 앱이 기억한 값이 아니다. final again = await repositories.quest.load(); expect(again.firstWhere((card) => card.id == questId).stage, QuestStage.rewarded); }); testWidgets('같은 날 두 번째 참여는 서버가 거부한다', (tester) async { final quests = await repositories.quest.load(); final attend = quests.firstWhere((card) => card.id == questId); await expectLater( repositories.quest.verify(attend), // 화면은 gRPC 상태가 아니라 에러 카탈로그 코드로 분기한다. throwsA(isA().having((e) => e.code, 'code', 'E-POP-03')), ); final uid = repositories.backend.requireUid; final ledger = await repositories.backend.db.collection('users/$uid/popTransactions').get(); expect(ledger.size, 1, reason: '거부된 호출이 원장을 늘리면 안 된다'); }); }