import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:fancake/core/data/backend.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:fancake/features/home/home_repository_firestore.dart'; import 'package:fancake/features/my/my_data.dart'; import 'package:fancake/features/my/my_repository_firestore.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; /// POP → 행성 반영 실증 — 별첨 1 ② 완료 시나리오 1 의 **읽는 쪽**. /// /// **무엇을 재는가.** 서버가 원장을 지나 쓴 값을 앱이 그대로 읽어 HOME·MY 수치로 /// 옮기는지를 잰다. 가짜 Firestore 가 아니라 **실제 Firestore 에뮬레이터**를 물고, /// 문서도 앱이 만든 것이 아니라 **Cloud Functions 가 쓴 것**을 읽는다. 두 단계가 /// 서로 다른 프로세스라, 필드 이름이 한 글자만 어긋나도 여기서 0 이 나온다. /// /// 실행 — 두 단계를 순서대로 돈다. /// /// ```bash /// # 1. 서버가 상태를 만든다(출석 지급 → 원장 → 기여 차감 → 등급·멤버 갱신) /// java -jar ~/.cache/firebase/emulators/cloud-firestore-emulator-v1.22.0.jar \ /// --host=0.0.0.0 --port=8080 & /// CI=1 FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 GCLOUD_PROJECT=demo-fanuzu \ /// npm --prefix firebase/functions test /// /// # 2. 앱이 그 상태를 읽는다 /// fvm flutter test integration_test/pop_loop_emulator_test.dart \ /// -d emulator-5554 --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 /// ``` /// /// `10.0.2.2` 는 Android 에뮬레이터가 보는 호스트 PC 주소다. /// /// **로그인만 흉내 낸다.** Auth 에뮬레이터는 `firebase` CLI 안에만 있어 이 환경에서 /// 띄우지 못한다(BUILD.md 2-1). UID 는 서버 테스트가 쓴 것과 같은 값을 넣고, 읽는 /// 경로는 실제 구현을 그대로 지난다. void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); /// `firebase/functions/test/quest_e2e.test.ts` 가 쓰는 UID. const uid = 'test-quest-e2e'; /// 출석 퀘스트의 **문서 ID**. 진행 문서도 같은 ID 로 선다(설계서 §2.1). const attendQuest = 'daily-checkin'; /// 호스트 PC 주소. Android 에뮬레이터는 `10.0.2.2` 로 본다. const host = String.fromEnvironment('FANUZU_EMULATOR_HOST', defaultValue: '10.0.2.2'); late Backend backend; setUpAll(() async { // `FirebaseBoot.init()` 을 쓰지 않는다. 그 경로는 Auth 에뮬레이터(9099)에도 // 붙는데, Auth 는 `firebase` CLI 안에만 있어 이 환경에서 뜨지 않는다(BUILD.md 2-1). // 거기서 실패하면 Firestore 연결까지 건너뛰어 앱이 실 서버를 바라본다. // // 이름 있는 앱을 따로 띄운다. Android 는 `google-services.json` 으로 `[DEFAULT]` // 앱을 자동 초기화하므로 그 자리를 덮을 수 없고, 덮지 않으면 프로젝트 ID 가 // 달라 에뮬레이터의 다른 칸을 읽는다. final app = await Firebase.initializeApp( name: 'fanuzu-emulator-check', options: const FirebaseOptions( apiKey: 'demo-key', appId: '1:0:android:0', messagingSenderId: '0', projectId: 'demo-fanuzu', ), ); final db = FirebaseFirestore.instanceFor(app: app) ..useFirestoreEmulator(host, 8080); backend = Backend( firestore: db, auth: MockFirebaseAuth( signedIn: true, mockUser: MockUser(uid: uid, email: 'fan@fanuzu.co.kr'), ), ); }); testWidgets('에뮬레이터를 물고 서버가 쓴 문서가 보인다', (tester) async { final user = await backend.db.doc('users/$uid').get(); expect(user.exists, isTrue, reason: '서버 E2E(quest_e2e.test.ts)를 먼저 돌려 상태를 만드십시오'); // 출석·후원을 지난 뒤의 서버 값이다. 앱이 쓴 값이 아니다. expect(user.data()!['totalContributedPop'], 3000); // 레벨은 누적 기여 POP 이 아니라 User EXP 에서 나온다(C-18) — 출석 1건 × 100(후원은 EXP 대상 아님). expect(user.data()!['userExp'], 100); expect(user.data()!['userLevel'], 1); expect(user.data()!['passportTier'], 'SILVER'); }); testWidgets('HOME 이 출석 보상 잔액과 행성 성장 수치를 서버 값에서 읽는다', (tester) async { final snapshot = await FirestoreHomeRepository(backend).load(); // 출석 1회 보상 10 POP — `fn-quest-attend` 가 원장을 지나 올린 활동 잔액. expect(snapshot.popBalance, 10); // 공동 성장 = 출석 보상 10 + 후원 3,000. 개인 기여와 구별한다. expect(snapshot.growth.currentPop, 3010); // 공동 Stage가 아직 없는 시나리오이므로 진행률은 확정하지 않는다. expect(snapshot.growth.growthPercent, isNull); expect(snapshot.growth.fandomName, '퀘스트 루프 팬덤'); }); testWidgets('MY 가 누적 기여와 등급을 서버 값에서 읽는다', (tester) async { final profile = await FirestoreMyRepository(backend).profile(); expect(profile.contributedPop, 3000); // 레벨은 User EXP 기반이다(C-18). 기여 POP 3,000 이 칭호를 올리지 않는다. expect(profile.userLevel, greaterThanOrEqualTo(1)); expect(profile.passportTier, PassportTier.silver); expect(profile.balancePop, 10); expect(profile.planetTiers.single.fandomName, '퀘스트 루프 팬덤'); expect(profile.planetTiers.single.contributedPop, 3000); }); testWidgets('같은 퀘스트를 다시 참여하면 서버가 막은 자국이 남아 있다', (tester) async { // 중복 참여 거부(E-POP-03)는 서버가 판정한다. 앱에서 보이는 자국은 진행 카운터다 — // 같은 날 두 번 눌러도 1 을 넘지 않는다. // // **경로가 퀘스트 문서 ID 다.** 앱이 목록을 그릴 때 읽는 자리와 서버가 쓰는 자리가 // 같아야 화면이 「보상완료」로 넘어간다. final progress = await backend.db.doc('users/$uid/questProgress/$attendQuest').get(); expect(progress.exists, isTrue); expect(progress.data()!['count'], 1); expect(progress.data()!['questId'], attendQuest); final ledger = await backend.db .collection('users/$uid/popTransactions') .where('reasonCode', isEqualTo: 'QUEST_ATTEND') .get(); expect(ledger.size, 1); }); }