import 'dart:async'; 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/auth/firebase_account_linker.dart'; import 'package:fancake/features/auth/social_providers.dart'; import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; /// LINE OIDC 제공자 — 실 로그인은 못 하고, /// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다. void main() { // Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned). TestWidgetsFlutterBinding.ensureInitialized(); setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'OIDC 테스트')); tearDown( () => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'OIDC 테스트 끝'), ); OidcSignInProvider line(fb.FirebaseAuth auth) => OidcSignInProvider( AuthProviderKind.line, providerId: 'oidc.line', firebaseAuth: auth, ); test('기본 목록: LINE 은 Firebase OIDC, 카카오는 SDK + 서버 커스텀 토큰', () { final providers = { for (final p in AuthRepository().providers) p.provider: p, }; final p = providers[AuthProviderKind.line]; expect(p, isA()); expect(p!.isLive, isTrue); expect( (p as OidcSignInProvider).providerId, FirebaseAccountLinker.providerIds[AuthProviderKind.line], ); expect(p.authProvider.providerId, 'oidc.line'); expect(providers[AuthProviderKind.kakao], isA()); }); test('Apple: Android 는 Firebase 웹 흐름(apple.com) · iOS 는 Apple SDK', () { final android = AuthRepository().providers.firstWhere( (p) => p.provider == AuthProviderKind.apple, ); expect(android, isA()); expect( (android as OidcSignInProvider).authProvider.providerId, 'apple.com', ); expect(android.missingConfig, isEmpty); debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); final ios = AuthRepository().providers.firstWhere( (p) => p.provider == AuthProviderKind.apple, ); expect(ios, isA()); }); test('Firebase 설정이 없으면 SDK 를 부르지 않고 미설정으로 닫는다', () async { FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '설정 없음'); final outcome = await line(_ThrowingAuth('should-not-call')).signIn(); expect(outcome, isA()); }); test('성공하면 LINE 계정으로 로그인된다', () async { final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'k-1')); final outcome = await line(auth).signIn(); expect(outcome, isA()); expect((outcome as AuthSucceeded).account.provider, AuthProviderKind.line); expect(outcome.account.uid, 'k-1'); }); test('인가 화면을 닫으면 Android·iOS 철자 모두 취소로 본다', () async { for (final code in ['web-context-canceled', 'web-context-cancelled']) { final outcome = await line(_ThrowingAuth(code)).signIn(); expect(outcome, isA(), reason: code); } }); test('콘솔에서 제공자를 안 켰으면 E-AUTH-11', () async { final outcome = await line(_ThrowingAuth('operation-not-allowed')).signIn(); expect((outcome as AuthFailed).code, 'E-AUTH-11'); }); test('Android 의 LINE 은 앱 자동 로그인을 끈다 · iOS 는 파라미터 없음', () { final line = AuthRepository().providers.firstWhere( (p) => p.provider == AuthProviderKind.line, ) as OidcSignInProvider; expect(line.authProvider.parameters, {'disable_auto_login': 'true'}); debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); expect(line.authProvider.parameters, isEmpty); }); testWidgets('Android: 앱으로 돌아왔는데 결과가 없으면 취소로 풀린다', (tester) async { late AuthOutcome outcome; line(_HangingAuth()).signIn().then((o) => outcome = o); void go(List states) => states.forEach(tester.binding.handleAppLifecycleStateChanged); const inactive = AppLifecycleState.inactive; const hidden = AppLifecycleState.hidden; go([inactive, hidden, AppLifecycleState.paused]); // Custom Tab 으로 나감 await tester.pump(const Duration(minutes: 1)); // 브라우저에 오래 있어도 그대로 go([hidden, inactive, AppLifecycleState.resumed]); // 앱으로 돌아옴 await tester.pump(const Duration(seconds: 9)); expect(outcome, isA()); }); group('계정 연결', () { test('같은 UID 에 oidc.line 을 붙인다', () async { final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr'); final auth = MockFirebaseAuth(signedIn: true, mockUser: user); final linker = FirebaseAccountLinker( providers: [line(auth)], firebaseAuth: auth, ); final outcome = await linker.link(AuthProviderKind.line); expect(outcome, isA()); expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line)); }); test('이메일이 다르거나 없는 LINE 계정도 붙는다 — 이메일 일치는 소유 확인이 아니다', () async { for (final email in ['someone@else.com', null]) { final user = _LinkingUser(linkedEmail: email); final auth = MockFirebaseAuth(signedIn: true, mockUser: user); final linker = FirebaseAccountLinker( providers: [line(auth)], firebaseAuth: auth, ); final outcome = await linker.link(AuthProviderKind.line); expect(outcome, isA(), reason: '$email'); expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line)); } }); test('다른 FANUZU 계정에 붙은 LINE 은 Firebase 거부를 E-AUTH-08 로 · 연결되지 않는다', () async { final user = _LinkingUser( linkedEmail: null, error: fb.FirebaseAuthException(code: 'credential-already-in-use'), ); final auth = MockFirebaseAuth(signedIn: true, mockUser: user); final linker = FirebaseAccountLinker( providers: [line(auth)], firebaseAuth: auth, ); final outcome = await linker.link(AuthProviderKind.line); expect((outcome as AuthFailed).code, 'E-AUTH-08'); expect(outcome.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.'); expect(linker.linkedAccounts.keys, isNot(contains(AuthProviderKind.line))); }); test('Google · Apple(자격증명 경로)도 이메일이 달라도 붙는다', () async { final auth = MockFirebaseAuth( signedIn: true, mockUser: MockUser(uid: 'u-1', email: 'fan@fanuzu.co.kr'), ); final outcome = await FirebaseAccountLinker( providers: [const _FakeCredential('other@gmail.com')], firebaseAuth: auth, ).link(AuthProviderKind.google); expect(outcome, isA()); }); test('세션이 만료됐으면 다시 로그인 안내(E-AUTH-12)', () async { final user = _LinkingUser( linkedEmail: null, error: fb.FirebaseAuthException(code: 'user-token-expired'), ); final auth = MockFirebaseAuth(signedIn: true, mockUser: user); final outcome = await FirebaseAccountLinker( providers: [line(auth)], firebaseAuth: auth, ).link(AuthProviderKind.line); expect((outcome as AuthFailed).code, 'E-AUTH-12'); }); }); } /// 이메일을 함께 주는 자격증명 제공자(Google 흉내). final class _FakeCredential implements SocialAuthProvider, CredentialProvider { const _FakeCredential(this.email); final String email; @override AuthProviderKind get provider => AuthProviderKind.google; @override bool get isLive => true; @override List get missingConfig => const []; @override Future signIn() async => const AuthCancelled(); @override Future signOut() async {} @override Future obtainCredential() async => ( credential: fb.GoogleAuthProvider.credential(idToken: 'id'), email: email, ); } class _ThrowingAuth extends MockFirebaseAuth { _ThrowingAuth(this.code); final String code; @override Future signInWithProvider(fb.AuthProvider provider) => throw fb.FirebaseAuthException(code: code); } /// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task. class _HangingAuth extends MockFirebaseAuth { @override Future signInWithProvider(fb.AuthProvider provider) => Completer().future; } /// Google 로 가입한 사용자. 연결하면 [linkedEmail] 을 가진 제공자 정보가 붙는다. // ignore: must_be_immutable class _LinkingUser extends MockUser { _LinkingUser({required this.linkedEmail, this.error}) : super( uid: 'u-1', email: 'fan@fanuzu.co.kr', providerData: [_info('google.com', 'fan@fanuzu.co.kr')], ); final String? linkedEmail; final fb.FirebaseAuthException? error; @override Future linkWithProvider(fb.AuthProvider provider) async { if (error case final e?) throw e; final result = await super.linkWithProvider(provider); providerData ..removeLast() ..add(_info(provider.providerId, linkedEmail)); return result; } } fb.UserInfo _info(String providerId, String? email) => // ignore: invalid_use_of_protected_member fb.UserInfo.fromJson({ 'uid': 'p-$providerId', 'email': email, 'providerId': providerId, 'isAnonymous': false, 'isEmailVerified': true, });