import 'dart:async'; import 'dart:io'; import 'package:fancake/app.dart'; import 'package:fancake/core/config/feature_flags.dart'; import 'package:fancake/core/firebase/firebase_boot.dart'; import 'package:fancake/core/router/app_router.dart'; import 'package:fancake/features/auth/auth.dart'; import 'package:fancake/features/auth/auth_repository.dart'; import 'package:fancake/features/auth/login_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; /// 2주차 스모크 — Firebase 설정 파일이 **없는 상태**(목 모드)에서 전부 통과해야 한다. void main() { setUp(() { FirebaseBoot.debugSetMode( FirebaseMode.mock, reason: '스모크 테스트 — 설정 파일 미투입 상황 재현', ); }); testWidgets('1. 앱이 예외 없이 뜨고 로그인 화면이 나온다', (tester) async { await tester.pumpWidget(FancakeApp(auth: AuthRepository())); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect(find.byKey(loginLogoKey), findsOneWidget); // Figma 2단계 — 「로그인」을 누르면 간편 로그인에 별첨 B v1.4 확정 4종이 있다. await tester.tap(find.widgetWithText(FilledButton, '로그인')); await tester.pumpAndSettle(); for (final provider in AuthRepository().providers) { expect( find.text(providerButtonLabel(provider.provider)), findsOneWidget, reason: provider.provider.name, ); } }); // 별첨 B v1.4 — *이메일/비밀번호 가입 및 X 로그인은 제외합니다.* // 발주사가 「질의 없이 임의 변경」을 지적한 뒤의 회신이라 회귀 가드를 건다. group('이메일 가입은 사용자에게 노출되지 않는다', () { test('노출 제공자 목록은 Google·Apple·Kakao·LINE 4종뿐이다', () { final kinds = AuthRepository().providers.map((p) => p.provider).toList(); expect(kinds, const [ AuthProviderKind.google, AuthProviderKind.apple, AuthProviderKind.kakao, AuthProviderKind.line, ]); expect(kinds, isNot(contains(AuthProviderKind.email))); }); testWidgets('로그인 화면에 이메일 버튼·가입·재설정 입구가 없다', (tester) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); await tester.pumpWidget(FancakeApp(auth: AuthRepository())); await tester.pumpAndSettle(); expect(find.text(AuthProviderKind.email.label), findsNothing); expect(find.text('이메일'), findsNothing); expect(find.text('비밀번호'), findsNothing); expect(find.text('계정 만들기'), findsNothing); expect(find.text('비밀번호 재설정'), findsNothing); }); test('릴리스 빌드에는 QA 이메일 경로가 아예 없다', () async { final auth = AuthRepository(releaseMode: true); expect(auth.qaEmailProvider, isNull); final outcome = await auth.signInWithEmail( email: 'qa-test@fanuzu.co.kr', password: 'password12', ); expect(outcome, isA()); expect(auth.isSignedIn, isFalse); }); test('디버그 빌드에서는 QA 이메일 경로가 살아 있다', () { expect(AuthRepository(releaseMode: false).qaEmailProvider, isNotNull); }); }); testWidgets('저장된 세션이 있으면 로그인 화면 대신 5탭 셸로 복원된다', (tester) async { final session = _SmokeSession( const AuthAccount(uid: 'restored-user', provider: SocialProvider.google), ); final auth = AuthRepository(session: session, releaseMode: true); await tester.pumpWidget(FancakeApp(auth: auth)); await tester.pumpAndSettle(); expect(find.byType(NavigationBar), findsOneWidget); expect(find.text('목 세션으로 탭 둘러보기'), findsNothing); auth.dispose(); }); testWidgets('2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다', (tester) async { await _enterShell(tester); final rendered = tester .widgetList( find.descendant( of: find.byType(NavigationBar), matching: find.byType(NavigationDestination), ), ) .map((d) => d.label) .toList(); // 별첨 B v1.4 — STATION 은 사용자 탭에서 비노출. 계약(5탭)은 그대로 두고 // 화면에 그리는 목록만 줄인다. expect(rendered, ['HOME', 'INSIDE', 'GALAXY', 'MY']); expect(rendered, visibleTabs(FeatureFlags()).map((t) => t.label).toList()); expect(find.text('STATION'), findsNothing); }); // 계약(견적서 v2.1 · craft-allowlist.yaml · 시안 탭바)은 5탭 그대로다. // 화면에서 뺀 것과 계약에서 뺀 것은 다른 일이고, 뒤엣것은 하지 않았다. test('2-1. 계약 고정 5탭 표기·순서는 그대로다', () { expect(appTabs.map((t) => t.label).toList(), _contractTabs()); }); group('STATION 은 원격 플래그로 켠다 (별첨 B v1.4 · 재배포 없이)', () { test('플래그 기본값은 꺼짐이다', () { expect(FeatureFlags().stationTab, isFalse); expect(visibleTabs(FeatureFlags()), hasLength(4)); }); test('config/features 의 stationEnabled 가 true 면 5탭이 된다', () async { final source = StreamController>(); final flags = FeatureFlags(source: source.stream); addTearDown(flags.dispose); source.add({FeatureFlags.stationKey: true}); await Future.delayed(Duration.zero); expect(flags.stationTab, isTrue); expect( visibleTabs(flags).map((t) => t.label).toList(), appTabs.map((t) => t.label).toList(), ); await source.close(); }); test('문자열 "true" 나 읽기 실패로는 켜지지 않는다', () async { final source = StreamController>(); final flags = FeatureFlags(source: source.stream); addTearDown(flags.dispose); source.add({FeatureFlags.stationKey: 'true'}); source.addError(StateError('permission-denied')); await Future.delayed(Duration.zero); expect(flags.stationTab, isFalse); await source.close(); }); test('STATION 라우트는 남아 있다 — 비노출이지 삭제가 아니다', () { final station = appTabs.firstWhere((t) => t.label == 'STATION'); expect(station.path, '/station'); }); }); // A-01 회귀 가드 — 목 우회 입구가 배포 앱에 새어 나가지 않는지. // FirebaseBoot.init() 은 모든 예외를 삼키고 목 모드로 내려가므로, 설정 파일이 정상 // 투입된 릴리스 빌드라도 초기화가 실패하면 목 모드가 된다. 그 상태를 그대로 재현한다. group('릴리스 빌드에서는 목 우회 입구가 열리지 않는다', () { test('목 모드 + 릴리스면 isMockSessionAvailable 이 false 다', () { final auth = AuthRepository(releaseMode: true); expect(FirebaseBoot.isLive, isFalse, reason: '목 모드 전제 확인'); expect(auth.isMockSessionAvailable, isFalse); }); test('릴리스에서 signInWithMockSession 을 불러도 로그인되지 않는다', () { final auth = AuthRepository(releaseMode: true)..signInWithMockSession(); expect(auth.isSignedIn, isFalse); expect(auth.account, isNull); }); testWidgets('릴리스 빌드 로그인 화면에 목 세션 버튼이 없다', (tester) async { await tester.pumpWidget( FancakeApp(auth: AuthRepository(releaseMode: true)), ); await tester.pumpAndSettle(); expect(find.text('목 세션으로 탭 둘러보기'), findsNothing); expect( find.byType(NavigationBar), findsNothing, reason: '셸에 진입할 수 없어야 한다', ); // 소셜 버튼은 그대로 있어야 한다 — 우회 입구만 막는 것이지 로그인을 막는 게 아니다. await tester.tap(find.widgetWithText(FilledButton, '로그인')); await tester.pumpAndSettle(); expect( find.text(providerButtonLabel(SocialProvider.google)), findsOneWidget, ); }); testWidgets('디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)', (tester) async { await tester.pumpWidget( FancakeApp(auth: AuthRepository(releaseMode: false)), ); await tester.pumpAndSettle(); // 목 세션 입구는 QA 폼과 같은 숨은 입구(로고 길게 누르기) 안에 있다. await tester.longPress(find.byKey(loginLogoKey)); await tester.pumpAndSettle(); expect(find.text('목 세션으로 탭 둘러보기'), findsOneWidget); }); }); // 화면에 나가는 문자열 전체를 훑는다. reason 만 고쳤다가 제공자 missingConfig 로 // 리포 경로가 그대로 새어 나간 적이 있다(에뮬레이터 실기동 중 발견). test('제공자 4종의 미투입 안내에 내부 파일 경로가 들어가지 않는다', () { const forbidden = [ 'google-services.json', 'GoogleService-Info.plist', 'app/', '.plist', '.json', ]; for (final p in AuthRepository().providers) { for (final item in p.missingConfig) { for (final bad in forbidden) { expect( item.contains(bad), isFalse, reason: '${p.provider.name} 의 미투입 안내에 "$bad" 가 들어 있다: $item', ); } } } }); test('화면에 띄우는 사유에 내부 파일 경로가 들어가지 않는다', () { FirebaseBoot.debugSetMode( FirebaseMode.mock, reason: 'Firebase 설정이 아직 투입되지 않아 임시(목) 모드로 동작 중입니다.', ); expect(FirebaseBoot.reason, isNot(contains('google-services.json'))); expect(FirebaseBoot.reason, isNot(contains('GoogleService-Info.plist'))); expect(FirebaseBoot.reason, isNot(contains('/'))); }); testWidgets('3. 탭을 전환하면 각 탭 루트 화면이 바뀐다', (tester) async { await _enterShell(tester); const roots = { 'HOME': ('S-H-HOME', '오늘의 퀘스트'), 'INSIDE': ('S-I-LOUNGE', '팬덤 라운지'), 'GALAXY': ('S-G-PLANET', '내 행성'), // STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다. 'MY': ('S-M-HOME', '마이 페이지'), }; for (final entry in roots.entries) { await tester.tap( find.descendant( of: find.byType(NavigationBar), matching: find.text(entry.key), ), ); await tester.pumpAndSettle(); final (screenId, screenName) = entry.value; expect( find.text(screenId), findsOneWidget, reason: '${entry.key} → $screenId', ); // AppBar 제목과 본문 제목 두 군데에 나온다. expect( find.text(screenName), findsWidgets, reason: '${entry.key} → $screenName', ); expect( find.textContaining('wireframe/'), findsNothing, reason: '${entry.key} 화면에 내부 저장소 경로가 노출되면 안 된다', ); expect( find.textContaining('screen-spec'), findsNothing, reason: '${entry.key} 화면에 내부 명세 경로가 노출되면 안 된다', ); } }); } /// 로그인 화면에서 목 세션으로 들어가 5탭 셸까지 도달한다. /// /// 실 소셜 로그인은 Firebase 설정 파일이 없으면 물리적으로 불가능하므로, /// 목 모드 전용 입구를 쓴다(AuthRepository.isMockSessionAvailable). Future _enterShell(WidgetTester tester) async { await tester.pumpWidget(FancakeApp(auth: AuthRepository())); await tester.pumpAndSettle(); await tester.longPress(find.byKey(loginLogoKey)); await tester.pumpAndSettle(); final enter = find.text('목 세션으로 탭 둘러보기'); await tester.ensureVisible(enter); await tester.pumpAndSettle(); await tester.tap(enter); await tester.pumpAndSettle(); expect(find.byType(NavigationBar), findsOneWidget); } /// `design/craft-allowlist.yaml` 의 `APP_TABS` 를 읽어 온다. /// 앱 코드가 계약 표기에서 벗어나면 여기서 깨진다. /// /// 계약 정본은 상위 저장소(p-fancake)에 있고 이 저장소(p-fanuzu)는 분리돼 있어 /// 상대 경로가 체크아웃 위치를 탄다. 그래서 저장소 안에 사본을 두고 /// **FANUZU_DESIGN_DIR → 저장소 내 design/ → 형제 경로** 순으로 본다. /// 사본이 있으므로 git worktree 에서도 환경 변수 없이 통과한다. /// /// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면 /// 계약 대조가 조용히 사라진다. List _contractTabs() { final candidates = [ if (Platform.environment['FANUZU_DESIGN_DIR'] case final dir?) '$dir/craft-allowlist.yaml', 'design/craft-allowlist.yaml', '../design/craft-allowlist.yaml', '../p-fancake/design/craft-allowlist.yaml', ]; final file = candidates .map(File.new) .firstWhere( (f) => f.existsSync(), orElse: () => throw StateError( 'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. ' '찾아본 곳: ${candidates.join(' · ')}', ), ); final line = file.readAsLinesSync().firstWhere( (l) => l.trimLeft().startsWith('APP_TABS:'), orElse: () => throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'), ); final inner = RegExp(r'\[(.*)\]').firstMatch(line)!.group(1)!; return inner.split(',').map((s) => s.trim()).toList(); } final class _SmokeSession implements AuthSession { const _SmokeSession(this.currentAccount); @override final AuthAccount? currentAccount; @override Stream get accountChanges => const Stream.empty(); @override Future signOut() async {} }