420 lines
16 KiB
Dart
420 lines
16 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
|
||
import 'package:fancake/app.dart';
|
||
import 'package:fancake/core/config/feature_flags.dart';
|
||
import 'package:fancake/core/data/repositories.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<AuthFailed>());
|
||
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('회원가입 직후에는 가입 완료 화면(182:29344)을 거쳐 셸로 들어간다', (tester) async {
|
||
final auth = AuthRepository(
|
||
providers: [_SignupProvider()],
|
||
releaseMode: true,
|
||
);
|
||
await tester.pumpWidget(FancakeApp(auth: auth));
|
||
await tester.pumpAndSettle();
|
||
|
||
await auth.signIn(SocialProvider.google, signUp: true);
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('회원가입이 완료되었습니다'), findsOneWidget);
|
||
expect(find.byType(NavigationBar), findsNothing);
|
||
|
||
await tester.tap(find.text('시작'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(NavigationBar), findsOneWidget);
|
||
auth.dispose();
|
||
});
|
||
|
||
testWidgets('2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다', (tester) async {
|
||
await _enterShell(tester);
|
||
|
||
final rendered = tester
|
||
.widgetList<NavigationDestination>(
|
||
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<Map<String, Object?>>();
|
||
final flags = FeatureFlags(source: source.stream);
|
||
addTearDown(flags.dispose);
|
||
|
||
source.add({FeatureFlags.stationKey: true});
|
||
await Future<void>.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<Map<String, Object?>>();
|
||
final flags = FeatureFlags(source: source.stream);
|
||
addTearDown(flags.dispose);
|
||
|
||
source.add({FeatureFlags.stationKey: 'true'});
|
||
source.addError(StateError('permission-denied'));
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(flags.stationTab, isFalse);
|
||
await source.close();
|
||
});
|
||
|
||
test('STATION 라우트와 저장소는 남아 있다 — 비노출이지 삭제가 아니다', () {
|
||
final station = appTabs.firstWhere((t) => t.label == 'STATION');
|
||
expect(station.path, '/station');
|
||
expect(Repositories(live: false).station, isNotNull);
|
||
});
|
||
});
|
||
|
||
// 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);
|
||
|
||
// 5탭 모두 실제 화면이 들어갔다. 자리표시가 남아 있으면 발주사가 설치본에서
|
||
// 「2주차 범위」 문구를 보게 되므로, 탭마다 그 화면의 고정 구획명으로 확인한다.
|
||
// STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다.
|
||
const implemented = {
|
||
'HOME': 'TODAY’S QUEST',
|
||
'INSIDE': '최신순',
|
||
'GALAXY': 'GALAXY',
|
||
'MY': '행성/성운',
|
||
};
|
||
final roots = implemented.keys;
|
||
|
||
for (final tab in roots) {
|
||
await tester.tap(
|
||
find.descendant(
|
||
of: find.byType(NavigationBar),
|
||
matching: find.text(tab),
|
||
),
|
||
);
|
||
// GALAXY 는 궤도 회전이 멈추지 않아 pumpAndSettle 이 영원히 기다린다.
|
||
// 탭 전환은 프레임 몇 장이면 끝나므로 시간을 정해 넘긴다.
|
||
for (var i = 0; i < 6; i++) {
|
||
await tester.pump(const Duration(milliseconds: 120));
|
||
}
|
||
|
||
// GALAXY 는 궤도 회전이 멈추지 않아 pumpAndSettle 이 끝나지 않는다.
|
||
expect(
|
||
find.text(implemented[tab]!),
|
||
findsWidgets,
|
||
reason: '$tab → ${implemented[tab]}',
|
||
);
|
||
// 자리표시가 되살아나면 여기서 잡힌다.
|
||
expect(
|
||
find.textContaining('2주차 범위'),
|
||
findsNothing,
|
||
reason: '$tab 이 자리표시로 돌아갔다',
|
||
);
|
||
expect(
|
||
find.textContaining('wireframe/'),
|
||
findsNothing,
|
||
reason: '$tab 화면에 내부 저장소 경로가 노출되면 안 된다',
|
||
);
|
||
expect(
|
||
find.textContaining('screen-spec'),
|
||
findsNothing,
|
||
reason: '\$tab 화면에 내부 명세 경로가 노출되면 안 된다',
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 로그인 화면에서 목 세션으로 들어가 5탭 셸까지 도달한다.
|
||
///
|
||
/// 실 소셜 로그인은 Firebase 설정 파일이 없으면 물리적으로 불가능하므로,
|
||
/// 목 모드 전용 입구를 쓴다(AuthRepository.isMockSessionAvailable).
|
||
Future<void> _enterShell(WidgetTester tester) async {
|
||
// 홈 히어로가 시안대로 첫 화면의 78% 를 차지하므로 기본 800×600 창에서는 아래
|
||
// 구획이 지연 빌드되지 않는다. 실기기에 가까운 세로 길이를 준다(F3 테스트와 같다).
|
||
// 목 모드에서는 이클립스 경보 배너와 라이브 띠가 히어로 아래에 더 붙으므로
|
||
// 2400 으로는 TODAY'S QUEST 가 지연 빌드 밖으로 밀린다.
|
||
tester.view.physicalSize = const Size(1080, 3200);
|
||
tester.view.devicePixelRatio = 2;
|
||
addTearDown(tester.view.reset);
|
||
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` 를 읽어 온다.
|
||
/// 앱 코드가 계약 표기에서 벗어나면 여기서 깨진다.
|
||
///
|
||
/// 계약 파일은 저장소 루트 `design/` 에 있고, `app/design/` 에 같은 사본을 둔다.
|
||
/// **FANUZU_DESIGN_DIR → app/design/ → 저장소 루트 design/** 순으로 본다.
|
||
///
|
||
/// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면
|
||
/// 계약 대조가 조용히 사라진다.
|
||
///
|
||
/// **사본 표류도 실패로 끝낸다.** 후보가 둘 이상 보이면(환경 변수로 가리킨 정본과
|
||
/// 저장소 내 사본이 함께 있는 경우) `APP_TABS` 줄을 서로 비교한다. 사본만 고치거나
|
||
/// 정본만 고치면 저장소마다 다른 답이 나오고, 그건 대조가 아니라 우연이다.
|
||
List<String> _contractTabs() {
|
||
final candidates = [
|
||
if (Platform.environment['FANUZU_DESIGN_DIR'] case final dir?)
|
||
'$dir/craft-allowlist.yaml',
|
||
'design/craft-allowlist.yaml',
|
||
'../design/craft-allowlist.yaml',
|
||
];
|
||
final found = candidates.map(File.new).where((f) => f.existsSync()).toList();
|
||
if (found.isEmpty) {
|
||
throw StateError(
|
||
'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. '
|
||
'찾아본 곳: ${candidates.join(' · ')}',
|
||
);
|
||
}
|
||
final lines = {for (final file in found) _appTabsLine(file)};
|
||
if (lines.length > 1) {
|
||
throw StateError(
|
||
'craft-allowlist.yaml 의 APP_TABS 가 사본마다 다르다. 정본과 사본을 맞추라. '
|
||
'${found.map((f) => f.path).join(' · ')} → ${lines.join(' / ')}',
|
||
);
|
||
}
|
||
final inner = RegExp(r'\[(.*)\]').firstMatch(lines.single)!.group(1)!;
|
||
return inner.split(',').map((s) => s.trim()).toList();
|
||
}
|
||
|
||
/// `APP_TABS:` 한 줄. 없으면 파일 이름을 붙여 실패한다.
|
||
String _appTabsLine(File file) => file.readAsLinesSync().firstWhere(
|
||
(l) => l.trimLeft().startsWith('APP_TABS:'),
|
||
orElse: () =>
|
||
throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'),
|
||
);
|
||
|
||
final class _SmokeSession implements AuthSession {
|
||
const _SmokeSession(this.currentAccount);
|
||
|
||
@override
|
||
final AuthAccount? currentAccount;
|
||
|
||
@override
|
||
Stream<AuthAccount?> get accountChanges => const Stream.empty();
|
||
|
||
@override
|
||
Future<void> signOut() async {}
|
||
}
|
||
|
||
final class _SignupProvider implements SocialAuthProvider {
|
||
@override
|
||
AuthProviderKind get provider => SocialProvider.google;
|
||
@override
|
||
bool get isLive => true;
|
||
@override
|
||
List<String> get missingConfig => const [];
|
||
@override
|
||
Future<AuthOutcome> signIn() async => const AuthSucceeded(
|
||
AuthAccount(uid: 'new-user', provider: SocialProvider.google),
|
||
);
|
||
@override
|
||
Future<void> signOut() async {}
|
||
}
|