diff --git a/app/firebase/firebase.json b/app/firebase/firebase.json index 494c2bf..79ec08b 100644 --- a/app/firebase/firebase.json +++ b/app/firebase/firebase.json @@ -16,6 +16,9 @@ }, "functions": { "port": 5001 + }, + "hub": { + "port": 4400 } }, "functions": [ diff --git a/app/firebase/functions/package.json b/app/firebase/functions/package.json index 3cba6b1..5d26ebb 100644 --- a/app/firebase/functions/package.json +++ b/app/firebase/functions/package.json @@ -4,7 +4,7 @@ "private": true, "description": "FANUZU Cloud Functions — 2주차 Firestore 설계서 §8 인터페이스 목록의 구현", "type": "module", - "main": "lib/index.js", + "main": "lib/src/index.js", "engines": { "node": "22" }, diff --git a/app/integration_test/auth_emulator_test.dart b/app/integration_test/auth_emulator_test.dart new file mode 100644 index 0000000..b435d23 --- /dev/null +++ b/app/integration_test/auth_emulator_test.dart @@ -0,0 +1,123 @@ +import 'package:cloud_firestore/cloud_firestore.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:firebase_auth/firebase_auth.dart' as fb; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +/// 로그인 실동작 실증 — 별첨 2 2주차 확인 항목 「앱 설치·로그인·탭 이동 확인」. +/// +/// **왜 에뮬레이터로 재는가.** 실 Firebase 프로젝트는 계정 명의가 확정되어야 +/// 열 수 있다(계정·권한 이전 별첨 A-3). 그 사이 로그인 코드가 실제로 도는지 +/// 확인할 방법이 없으면, 설정 파일이 들어오는 날 처음 돌려 보게 된다. +/// +/// 이 테스트는 **설정 파일 대신 에뮬레이터를 물리고 실제 왕복을 돈다.** +/// 통과한다는 것은 설정 파일만 들어오면 그대로 동작한다는 뜻이다. +/// +/// 실행: +/// firebase emulators:start --only auth,firestore,functions --project demo-fanuzu +/// flutter test integration_test/auth_emulator_test.dart \ +/// --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 -d +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await FirebaseBoot.init(); + }); + + testWidgets('에뮬레이터를 물면 실 모드로 뜬다', (tester) async { + expect(FirebaseBoot.useEmulator, isTrue, + reason: '--dart-define=FANUZU_EMULATOR_HOST 를 주고 실행하십시오'); + expect(FirebaseBoot.isLive, isTrue, reason: FirebaseBoot.reason); + }); + + testWidgets('이메일로 가입하면 계정이 만들어지고 로그인 상태가 된다', (tester) async { + final auth = AuthRepository(); + final email = 'fan+${DateTime.now().microsecondsSinceEpoch}@fanuzu.co.kr'; + + final outcome = await auth.signInWithEmail( + email: email, + password: 'password12', + signUp: true, + ); + + expect(outcome, isA(), + reason: outcome is AuthFailed ? '${outcome.code} ${outcome.message}' : ''); + expect(auth.isSignedIn, isTrue); + expect(auth.account!.email, email); + expect(fb.FirebaseAuth.instance.currentUser, isNotNull); + }); + + testWidgets('가입한 계정으로 다시 로그인된다', (tester) async { + final auth = AuthRepository(); + final email = 'fan+${DateTime.now().microsecondsSinceEpoch}@fanuzu.co.kr'; + await auth.signInWithEmail( + email: email, password: 'password12', signUp: true, + ); + await fb.FirebaseAuth.instance.signOut(); + + final again = await auth.signInWithEmail( + email: email, password: 'password12', + ); + expect(again, isA()); + }); + + testWidgets('틀린 비밀번호는 카탈로그 코드로 돌아온다', (tester) async { + final auth = AuthRepository(); + final email = 'fan+${DateTime.now().microsecondsSinceEpoch}@fanuzu.co.kr'; + await auth.signInWithEmail( + email: email, password: 'password12', signUp: true, + ); + + final wrong = await auth.signInWithEmail( + email: email, password: 'wrong-password', + ); + expect(wrong, isA()); + // 「등록되지 않은 이메일」과 갈라 주지 않는다 — 가입 목록을 훑을 수 있다. + expect((wrong as AuthFailed).code, startsWith('E-AUTH-')); + }); + + testWidgets('가입하면 Auth 트리거가 계정·지갑 문서를 만든다', (tester) async { + final auth = AuthRepository(); + final email = 'fan+${DateTime.now().microsecondsSinceEpoch}@fanuzu.co.kr'; + await auth.signInWithEmail( + email: email, password: 'password12', signUp: true, + ); + final uid = auth.account!.uid; + + // fn-auth-oncreate 는 배경 트리거라 즉시 도착하지 않는다. 몇 초 기다린다. + Map? user; + for (var i = 0; i < 20 && user == null; i++) { + await Future.delayed(const Duration(milliseconds: 500)); + final snap = await FirebaseFirestore.instance.doc('users/$uid').get(); + user = snap.data(); + } + + expect(user, isNotNull, reason: 'fn-auth-oncreate 가 계정 문서를 만들지 않았습니다'); + expect(user!['status'], 'active'); + expect(user['totalContributedPop'], 0); + + final wallet = + await FirebaseFirestore.instance.doc('users/$uid/wallet/main').get(); + expect(wallet.exists, isTrue); + expect(wallet.get('activityBalance'), 0); + }); + + testWidgets('클라이언트는 지갑을 직접 쓸 수 없다', (tester) async { + final auth = AuthRepository(); + final email = 'fan+${DateTime.now().microsecondsSinceEpoch}@fanuzu.co.kr'; + await auth.signInWithEmail( + email: email, password: 'password12', signUp: true, + ); + final uid = auth.account!.uid; + + // 별첨 1 ② 「클라이언트 단독 지급 0건」이 규칙 층에서 실제로 막히는지 본다. + await expectLater( + FirebaseFirestore.instance + .doc('users/$uid/wallet/main') + .set({'activityBalance': 999999}), + throwsA(isA()), + ); + }); +} diff --git a/app/lib/core/firebase/firebase_boot.dart b/app/lib/core/firebase/firebase_boot.dart index 47ed4e5..c304801 100644 --- a/app/lib/core/firebase/firebase_boot.dart +++ b/app/lib/core/firebase/firebase_boot.dart @@ -1,7 +1,11 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_functions/cloud_functions.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; +import '../data/backend.dart'; + /// Firebase 구동 모드. enum FirebaseMode { /// 설정 파일이 투입돼 Firebase 가 실제로 초기화된 상태. @@ -46,8 +50,9 @@ abstract final class FirebaseBoot { /// 에뮬레이터 호스트. `--dart-define=FANUZU_EMULATOR_HOST=127.0.0.1` 로 켠다. /// - /// **왜 두는가** — 실 Firebase 프로젝트가 열리기 전에도 로그인이 - /// 실제로 도는지 확인할 수 있어야 한다. + /// **왜 두는가** — 실 Firebase 프로젝트가 열리기 전에도 로그인·원장·규칙이 + /// 실제로 도는지 확인할 수 있어야 한다. 설정 파일을 기다리는 동안 이 경로가 + /// 없으면 서버 코드가 한 줄도 실행되지 않은 채 쌓인다. /// /// 릴리스 빌드에서는 열리지 않는다. 배포된 앱이 로컬 호스트를 바라보면 /// 아무것도 동작하지 않고 원인도 보이지 않는다. @@ -89,6 +94,9 @@ abstract final class FirebaseBoot { try { await Firebase.initializeApp(options: _emulatorOptions); FirebaseAuth.instance.useAuthEmulator(emulatorHost, 9099); + FirebaseFirestore.instance.useFirestoreEmulator(emulatorHost, 8080); + FirebaseFunctions.instanceFor(region: Backend.region) + .useFunctionsEmulator(emulatorHost, 5001); _mode = FirebaseMode.live; _reason = '로컬 에뮬레이터에 연결했습니다. 실 서버가 아닙니다.'; _detail = 'emulator host: $emulatorHost'; diff --git a/app/pubspec.lock b/app/pubspec.lock index 8cce898..a8e2be4 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -209,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" firebase_auth: dependency: "direct main" description: @@ -278,6 +286,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -304,6 +317,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" go_router: dependency: "direct main" description: @@ -384,6 +402,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.1" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" json_annotation: dependency: transitive description: @@ -496,6 +519,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -520,6 +551,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" rx: dependency: transitive description: @@ -597,6 +636,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -653,6 +700,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" xml: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 11d8304..b421042 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -36,6 +36,10 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + # 기기 위에서 앱을 띄워 에뮬레이터와 실제 왕복을 돈다. 위젯 테스트는 네트워크를 + # 타지 않으므로 「로그인 → 계정 문서 생성」은 여기서만 확인할 수 있다. + integration_test: + sdk: flutter flutter_lints: ^6.0.0 # Firestore 연동 코드를 에뮬레이터 없이 검증한다. 쿼리·정렬·커서까지 흉내 내므로 # 「무엇을 읽어 무엇으로 옮기는가」를 실제로 돌려 볼 수 있다.