1차 보완(0.1.1+14) 내용 합치기
This commit is contained in:
@@ -63,7 +63,10 @@ void main() {
|
||||
test('에러 카탈로그 ID 는 경로와 무관하게 E-AUTH-08 이다', () {
|
||||
// 문구만 갈랐다. 카탈로그를 손대면 Admin·QA 의 코드 대조가 어긋난다.
|
||||
expect(
|
||||
authCatalogCode(_exception('account-exists-with-different-credential')),
|
||||
authCatalogCode(
|
||||
_exception('account-exists-with-different-credential'),
|
||||
flow: AuthFlow.signIn,
|
||||
),
|
||||
'E-AUTH-08',
|
||||
);
|
||||
});
|
||||
@@ -87,7 +90,7 @@ void main() {
|
||||
// 「등록되지 않은 이메일」과 「비밀번호 오류」가 같은 문구로 나가야 한다.
|
||||
for (final code in ['user-not-found', 'wrong-password']) {
|
||||
expect(
|
||||
authMessage(_exception(code), flow: AuthFlow.signIn),
|
||||
authMessage(_exception(code), flow: AuthFlow.email),
|
||||
'이메일 또는 비밀번호를 확인해 주세요.',
|
||||
reason: code,
|
||||
);
|
||||
@@ -95,6 +98,47 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('소셜 웹 로그인 실패 · 중단 — 이메일 문구를 쓰지 않는다(10/8 실기기 LINE · R01)', () {
|
||||
const webCodes = [
|
||||
'web-context-already-presented',
|
||||
'web-network-request-failed',
|
||||
'web-internal-error',
|
||||
'internal-error',
|
||||
'invalid-credential',
|
||||
];
|
||||
|
||||
test('소셜 로그인 · 연결의 알 수 없는 실패는 E-AUTH-03 · 소셜 문구', () {
|
||||
for (final code in webCodes) {
|
||||
for (final flow in [AuthFlow.signIn, AuthFlow.link]) {
|
||||
expect(authCatalogCode(_exception(code), flow: flow), 'E-AUTH-03', reason: '$code $flow');
|
||||
expect(
|
||||
authMessage(_exception(code), flow: flow),
|
||||
isNot('이메일 또는 비밀번호를 확인해 주세요.'),
|
||||
reason: '$code $flow',
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
authMessage(_exception('web-context-already-presented'), flow: AuthFlow.signIn),
|
||||
'로그인이 완료되지 않았습니다. 다시 시도해 주세요.',
|
||||
);
|
||||
expect(
|
||||
authMessage(_exception('web-network-request-failed'), flow: AuthFlow.signIn),
|
||||
'네트워크 연결을 확인한 뒤 다시 시도해 주세요.',
|
||||
);
|
||||
expect(authMessage(_exception('internal-error'), flow: AuthFlow.signIn), '소셜 로그인에 실패했습니다.');
|
||||
});
|
||||
|
||||
test('이메일 폼만 E-AUTH-01 · 「이메일 또는 비밀번호」', () {
|
||||
expect(authCatalogCode(_exception('internal-error'), flow: AuthFlow.email), 'E-AUTH-01');
|
||||
expect(authMessage(_exception('internal-error'), flow: AuthFlow.email), '이메일 또는 비밀번호를 확인해 주세요.');
|
||||
});
|
||||
|
||||
test('취소는 성공이 아니고 다시 시도하라고 안내한다', () {
|
||||
expect(authNotice(const AuthCancelled()), '로그인이 완료되지 않았습니다. 다시 시도해 주세요.');
|
||||
});
|
||||
});
|
||||
|
||||
// 10/4 실기기 2-3 — Android Apple 로그인 다이얼로그에 「발주사 키」 · 설정 항목이 떴다.
|
||||
test('설정 전 제공자 안내는 내부 설정 항목을 내보내지 않는다', () {
|
||||
final notice = authNotice(
|
||||
|
||||
@@ -90,20 +90,31 @@ void main() {
|
||||
expect((outcome as AuthSucceeded).account.uid, 'u-1');
|
||||
});
|
||||
|
||||
test('다른 이메일이면 E-AUTH-08 · 이메일 안내 문구', () async {
|
||||
test('다른 계정에 붙은 카카오면 E-AUTH-08 · 세션 만료면 다시 로그인 안내', () async {
|
||||
final auth = MockFirebaseAuth(
|
||||
signedIn: true,
|
||||
mockUser: MockUser(uid: 'u-1'),
|
||||
);
|
||||
final outcome = await kakao(
|
||||
final inUse = await kakao(
|
||||
auth,
|
||||
serverError: FirebaseFunctionsException(
|
||||
code: 'already-exists',
|
||||
message: 'x',
|
||||
details: {'code': 'E-AUTH-08', 'reason': 'different-email'},
|
||||
details: {'code': 'E-AUTH-08'},
|
||||
),
|
||||
).link(auth.currentUser!);
|
||||
expect((outcome as AuthFailed).code, 'E-AUTH-08');
|
||||
expect((inUse as AuthFailed).code, 'E-AUTH-08');
|
||||
expect(inUse.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.');
|
||||
|
||||
final expired = await kakao(
|
||||
auth,
|
||||
serverError: FirebaseFunctionsException(
|
||||
code: 'permission-denied',
|
||||
message: 'x',
|
||||
details: {'code': 'E-AUTH-03', 'reason': 'session'},
|
||||
),
|
||||
).link(auth.currentUser!);
|
||||
expect((expired as AuthFailed).message, '보안을 위해 다시 로그인한 뒤 시도해 주세요.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:cloud_functions/cloud_functions.dart';
|
||||
import 'package:fancake/core/firebase/firebase_boot.dart';
|
||||
import 'package:fancake/features/auth/auth.dart';
|
||||
import 'package:fancake/features/auth/firebase_account_linker.dart';
|
||||
import 'package:fancake/features/auth/social_providers.dart';
|
||||
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// LINE — SDK 로그인(주입) → 서버 교환(주입) → signInWithCustomToken 계약.
|
||||
/// 토큰 검증·기존 oidc.line 사용자 매핑은 서버 테스트(functions/test/auth_line.test.ts)가 본다.
|
||||
void main() {
|
||||
setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'LINE 테스트'));
|
||||
tearDown(
|
||||
() => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'LINE 테스트 끝'),
|
||||
);
|
||||
|
||||
final sent = <Map<String, dynamic>>[];
|
||||
setUp(sent.clear);
|
||||
|
||||
LineSignInProvider line(
|
||||
MockFirebaseAuth auth, {
|
||||
Future<LineTokens> Function()? login,
|
||||
Object? serverError,
|
||||
String channelId = '2000000001',
|
||||
}) => LineSignInProvider(
|
||||
login:
|
||||
login ?? () async => (idToken: 'line-id', accessToken: 'line-access'),
|
||||
exchange: (payload) async {
|
||||
sent.add(payload);
|
||||
if (serverError != null) throw serverError;
|
||||
return {
|
||||
'token': 'custom-token',
|
||||
if (payload['link'] == true) 'linked': true,
|
||||
};
|
||||
},
|
||||
firebaseAuth: auth,
|
||||
channelId: channelId,
|
||||
);
|
||||
|
||||
test('성공 — LINE 토큰을 서버에 넘기고 커스텀 토큰으로 로그인한다', () async {
|
||||
final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'line:U1'));
|
||||
final outcome = await line(auth).signIn();
|
||||
expect(sent.single, {'idToken': 'line-id', 'accessToken': 'line-access'});
|
||||
expect((outcome as AuthSucceeded).account.uid, 'line:U1');
|
||||
expect(outcome.account.provider, AuthProviderKind.line);
|
||||
expect(auth.currentUser?.uid, 'line:U1');
|
||||
});
|
||||
|
||||
test('ID 토큰이 없으면(openid 미동의) 액세스 토큰만 넘긴다', () async {
|
||||
await line(
|
||||
MockFirebaseAuth(),
|
||||
login: () async => (idToken: null, accessToken: 'line-access'),
|
||||
).signIn();
|
||||
expect(sent.single, {'accessToken': 'line-access'});
|
||||
});
|
||||
|
||||
test('채널 ID 가 없으면 SDK·서버를 부르지 않고 미설정', () async {
|
||||
final outcome = await line(MockFirebaseAuth(), channelId: '').signIn();
|
||||
expect(outcome, isA<AuthNotConfigured>());
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'LINE 로그인 취소(Android CANCEL · iOS 3003)는 취소 · 서버를 부르지 않는다 · 다시 시도하면 된다',
|
||||
() async {
|
||||
for (final code in ['CANCEL', '3003']) {
|
||||
final outcome = await line(
|
||||
MockFirebaseAuth(),
|
||||
login: () async => throw PlatformException(code: code),
|
||||
).signIn();
|
||||
expect(outcome, isA<AuthCancelled>(), reason: code);
|
||||
}
|
||||
expect(sent, isEmpty);
|
||||
final retry = await line(
|
||||
MockFirebaseAuth(mockUser: MockUser(uid: 'line:U1')),
|
||||
).signIn();
|
||||
expect(retry, isA<AuthSucceeded>());
|
||||
},
|
||||
);
|
||||
|
||||
test('SDK 실패는 소셜 문구 E-AUTH-03', () async {
|
||||
final outcome = await line(
|
||||
MockFirebaseAuth(),
|
||||
login: () async =>
|
||||
throw PlatformException(code: 'AUTHENTICATION_AGENT_ERROR'),
|
||||
).signIn();
|
||||
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||
expect(outcome.message, '소셜 로그인에 실패했습니다.');
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test('서버 오류는 카탈로그 코드로 · 로그인하지 않는다', () async {
|
||||
final auth = MockFirebaseAuth();
|
||||
final outcome = await line(
|
||||
auth,
|
||||
serverError: FirebaseFunctionsException(
|
||||
code: 'permission-denied',
|
||||
message: 'LINE 인증을 확인하지 못했습니다.',
|
||||
details: {'code': 'E-AUTH-03'},
|
||||
),
|
||||
).signIn();
|
||||
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||
expect(outcome.message, '소셜 로그인에 실패했습니다.');
|
||||
expect(auth.currentUser, isNull);
|
||||
});
|
||||
|
||||
group('SNS 연동', () {
|
||||
test('로그인한 사용자에 link: true 로 붙인다', () async {
|
||||
final auth = MockFirebaseAuth(
|
||||
signedIn: true,
|
||||
mockUser: MockUser(uid: 'u-1'),
|
||||
);
|
||||
final linker = FirebaseAccountLinker(
|
||||
providers: [line(auth)],
|
||||
firebaseAuth: auth,
|
||||
);
|
||||
final outcome = await linker.link(AuthProviderKind.line);
|
||||
expect(sent.single, {
|
||||
'idToken': 'line-id',
|
||||
'accessToken': 'line-access',
|
||||
'link': true,
|
||||
});
|
||||
expect((outcome as AuthSucceeded).account.uid, 'u-1');
|
||||
});
|
||||
|
||||
test('연결 중 취소는 취소 · 서버를 부르지 않는다', () async {
|
||||
final auth = MockFirebaseAuth(
|
||||
signedIn: true,
|
||||
mockUser: MockUser(uid: 'u-1'),
|
||||
);
|
||||
final outcome = await line(
|
||||
auth,
|
||||
login: () async => throw PlatformException(code: 'CANCEL'),
|
||||
).link(auth.currentUser!);
|
||||
expect(outcome, isA<AuthCancelled>());
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'다른 계정에 붙은 LINE 이면 E-AUTH-08 · 이미 다른 LINE 이면 E-AUTH-09 · 세션 만료면 다시 로그인 안내',
|
||||
() async {
|
||||
final auth = MockFirebaseAuth(
|
||||
signedIn: true,
|
||||
mockUser: MockUser(uid: 'u-1'),
|
||||
);
|
||||
Future<AuthOutcome> failWith(Map<String, String> details) => line(
|
||||
auth,
|
||||
serverError: FirebaseFunctionsException(
|
||||
code: 'x',
|
||||
message: 'x',
|
||||
details: details,
|
||||
),
|
||||
).link(auth.currentUser!);
|
||||
|
||||
final inUse = await failWith({'code': 'E-AUTH-08'});
|
||||
expect((inUse as AuthFailed).code, 'E-AUTH-08');
|
||||
expect(inUse.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.');
|
||||
expect(
|
||||
((await failWith({'code': 'E-AUTH-09'})) as AuthFailed).code,
|
||||
'E-AUTH-09',
|
||||
);
|
||||
final expired = await failWith({
|
||||
'code': 'E-AUTH-03',
|
||||
'reason': 'session',
|
||||
});
|
||||
expect((expired as AuthFailed).message, '보안을 위해 다시 로그인한 뒤 시도해 주세요.');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
+138
-31
@@ -7,11 +7,11 @@ 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/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// LINE OIDC 제공자 — 실 로그인은 못 하고,
|
||||
/// Firebase 웹 흐름(OIDC) 제공자 — 지금은 Android Apple 이 쓴다(LINE 을 예시 제공자로 둔다). 실 로그인은 못 하고,
|
||||
/// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다.
|
||||
void main() {
|
||||
// Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned).
|
||||
@@ -27,18 +27,11 @@ void main() {
|
||||
firebaseAuth: auth,
|
||||
);
|
||||
|
||||
test('기본 목록: LINE 은 Firebase OIDC, 카카오는 SDK + 서버 커스텀 토큰', () {
|
||||
test('기본 목록: 카카오 · LINE 은 SDK + 서버 커스텀 토큰', () {
|
||||
final providers = {
|
||||
for (final p in AuthRepository().providers) p.provider: p,
|
||||
};
|
||||
final p = providers[AuthProviderKind.line];
|
||||
expect(p, isA<OidcSignInProvider>());
|
||||
expect(p!.isLive, isTrue);
|
||||
expect(
|
||||
(p as OidcSignInProvider).providerId,
|
||||
FirebaseAccountLinker.providerIds[AuthProviderKind.line],
|
||||
);
|
||||
expect(p.authProvider.providerId, 'oidc.line');
|
||||
expect(providers[AuthProviderKind.line], isA<LineSignInProvider>());
|
||||
expect(providers[AuthProviderKind.kakao], isA<KakaoSignInProvider>());
|
||||
});
|
||||
|
||||
@@ -68,18 +61,6 @@ void main() {
|
||||
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);
|
||||
@@ -94,6 +75,52 @@ void main() {
|
||||
expect(outcome, isA<AuthCancelled>());
|
||||
});
|
||||
|
||||
group('웹 흐름 중단 뒤 재시도 (10/8 실기기 LINE · R01)', () {
|
||||
late List<String> cleared;
|
||||
setUp(() {
|
||||
cleared = [];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(const MethodChannel('kr.co.fanuzu.app/auth'), (call) async {
|
||||
cleared.add(call.method);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
tearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(const MethodChannel('kr.co.fanuzu.app/auth'), null));
|
||||
|
||||
test('앞선 흐름이 남아 막히면(web-context-already-presented) 정리하고 한 번 다시 열어 로그인된다', () async {
|
||||
final auth = _StaleThenOkAuth();
|
||||
final outcome = await line(auth).signIn();
|
||||
expect(outcome, isA<AuthSucceeded>());
|
||||
expect(auth.calls, 2);
|
||||
expect(cleared, ['cancelPendingWebSignIn']);
|
||||
});
|
||||
|
||||
test('정리 뒤에도 막히면 소셜 문구로 실패 — 「이메일 또는 비밀번호」가 아니다', () async {
|
||||
final outcome = await line(_ThrowingAuth('web-context-already-presented')).signIn();
|
||||
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||
expect(outcome.message, '로그인이 완료되지 않았습니다. 다시 시도해 주세요.');
|
||||
});
|
||||
|
||||
test('망 오류도 소셜 경로 문구', () async {
|
||||
final outcome = await line(_ThrowingAuth('web-network-request-failed')).signIn();
|
||||
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||
expect(outcome.message, '네트워크 연결을 확인한 뒤 다시 시도해 주세요.');
|
||||
});
|
||||
|
||||
testWidgets('Android: 결과 없이 돌아와 취소로 풀릴 때 Firebase 대기도 정리한다', (tester) async {
|
||||
late AuthOutcome outcome;
|
||||
line(_HangingAuth()).signIn().then((o) => outcome = o);
|
||||
for (final s in [AppLifecycleState.inactive, AppLifecycleState.hidden, AppLifecycleState.paused,
|
||||
AppLifecycleState.hidden, AppLifecycleState.inactive, AppLifecycleState.resumed]) {
|
||||
tester.binding.handleAppLifecycleStateChanged(s);
|
||||
}
|
||||
await tester.pump(const Duration(seconds: 9));
|
||||
expect(outcome, isA<AuthCancelled>());
|
||||
expect(cleared, ['cancelPendingWebSignIn']);
|
||||
});
|
||||
});
|
||||
|
||||
group('계정 연결', () {
|
||||
test('같은 UID 에 oidc.line 을 붙인다', () async {
|
||||
final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr');
|
||||
@@ -109,8 +136,27 @@ void main() {
|
||||
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line));
|
||||
});
|
||||
|
||||
test('다른 이메일의 LINE 계정은 붙였다가 바로 떼고 E-AUTH-08', () async {
|
||||
final user = _LinkingUser(linkedEmail: 'someone@else.com');
|
||||
test('이메일이 다르거나 없는 LINE 계정도 붙는다 — 이메일 일치는 소유 확인이 아니다(Q-E8 · R01)', () 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<AuthSucceeded>(), 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)],
|
||||
@@ -120,14 +166,60 @@ void main() {
|
||||
final outcome = await linker.link(AuthProviderKind.line);
|
||||
|
||||
expect((outcome as AuthFailed).code, 'E-AUTH-08');
|
||||
expect(
|
||||
linker.linkedAccounts.keys,
|
||||
isNot(contains(AuthProviderKind.line)),
|
||||
expect(outcome.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.');
|
||||
expect(linker.linkedAccounts.keys, isNot(contains(AuthProviderKind.line)));
|
||||
});
|
||||
|
||||
test('Google · Apple(자격증명 경로)도 이메일이 달라도 붙는다(Q-E8)', () 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<AuthSucceeded>());
|
||||
});
|
||||
|
||||
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<String> get missingConfig => const [];
|
||||
@override
|
||||
Future<AuthOutcome> signIn() async => const AuthCancelled();
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
@override
|
||||
Future<SocialCredential?> obtainCredential() async => (
|
||||
credential: fb.GoogleAuthProvider.credential(idToken: 'id'),
|
||||
email: email,
|
||||
name: null,
|
||||
);
|
||||
}
|
||||
|
||||
class _ThrowingAuth extends MockFirebaseAuth {
|
||||
_ThrowingAuth(this.code);
|
||||
final String code;
|
||||
@@ -137,6 +229,19 @@ class _ThrowingAuth extends MockFirebaseAuth {
|
||||
throw fb.FirebaseAuthException(code: code);
|
||||
}
|
||||
|
||||
/// 앞선 웹 흐름이 남아 첫 시도가 막히는 Firebase(Android `web-context-already-presented`).
|
||||
class _StaleThenOkAuth extends MockFirebaseAuth {
|
||||
_StaleThenOkAuth() : super(mockUser: MockUser(uid: 'line-1'));
|
||||
var calls = 0;
|
||||
|
||||
@override
|
||||
Future<fb.UserCredential> signInWithProvider(fb.AuthProvider provider) {
|
||||
calls += 1;
|
||||
if (calls == 1) throw fb.FirebaseAuthException(code: 'web-context-already-presented');
|
||||
return super.signInWithProvider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
/// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task.
|
||||
class _HangingAuth extends MockFirebaseAuth {
|
||||
@override
|
||||
@@ -147,17 +252,19 @@ class _HangingAuth extends MockFirebaseAuth {
|
||||
/// Google 로 가입한 사용자. 연결하면 [linkedEmail] 을 가진 제공자 정보가 붙는다.
|
||||
// ignore: must_be_immutable
|
||||
class _LinkingUser extends MockUser {
|
||||
_LinkingUser({required this.linkedEmail})
|
||||
_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 String? linkedEmail;
|
||||
final fb.FirebaseAuthException? error;
|
||||
|
||||
@override
|
||||
Future<fb.UserCredential> linkWithProvider(fb.AuthProvider provider) async {
|
||||
if (error case final e?) throw e;
|
||||
final result = await super.linkWithProvider(provider);
|
||||
providerData
|
||||
..removeLast()
|
||||
@@ -166,7 +273,7 @@ class _LinkingUser extends MockUser {
|
||||
}
|
||||
}
|
||||
|
||||
fb.UserInfo _info(String providerId, String email) =>
|
||||
fb.UserInfo _info(String providerId, String? email) =>
|
||||
// ignore: invalid_use_of_protected_member
|
||||
fb.UserInfo.fromJson({
|
||||
'uid': 'p-$providerId',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:fancake/core/data/backend.dart';
|
||||
import 'package:fancake/core/monetization/rewarded_ad.dart';
|
||||
import 'package:fancake/features/quest/location_consent.dart';
|
||||
import 'package:fancake/features/quest/place_picker_screen.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart' show LatLng;
|
||||
import 'package:fancake/features/quest/quest_ad_screens.dart';
|
||||
@@ -430,9 +431,19 @@ void main() {
|
||||
// 시안 서체·입력 테마를 적용하면 폼이 길어져 기본 창에서는 이 행이 접힌다.
|
||||
await tester.ensureVisible(find.textContaining('목표 위치'));
|
||||
await tester.pumpAndSettle();
|
||||
SampleLocationConsent.shared.value = false;
|
||||
await tester.tap(find.textContaining('목표 위치'));
|
||||
await tester.pumpAndSettle();
|
||||
// 위치기반 약관(Q-E1 · R02) — 처음이면 지도 전에 묻는다. 「나중에」여도 지도에서 직접 고를 수 있다.
|
||||
expect(find.text('위치기반 서비스 이용약관'), findsOneWidget);
|
||||
await tester.tap(find.text('나중에'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PlacePickerScreen), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<PlacePickerScreen>(find.byType(PlacePickerScreen)).useDeviceLocation,
|
||||
isFalse,
|
||||
);
|
||||
expect(SampleLocationConsent.shared.records, isEmpty);
|
||||
// 위치를 정하지 않고 닫으면 장소가 비어 완료는 그대로 잠겨 있다(선택 값은 아래 위치 선택 테스트).
|
||||
await tester.tap(find.byType(CloseButton));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -670,6 +681,51 @@ void main() {
|
||||
expect(find.text('광고 추가 보상 +20 기여 POP'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('위치기반 약관 — 위치기능 최초 사용 직전(Q-E1 · R02)', () {
|
||||
Future<_AdRepo> openPlace(WidgetTester tester) async {
|
||||
final repo = _AdRepo();
|
||||
await pumpWide(tester, QuestDetailScreen(questId: 'q4', repository: repo));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, QuestStage.notStarted.buttonLabel));
|
||||
await tester.pumpAndSettle();
|
||||
return repo;
|
||||
}
|
||||
|
||||
testWidgets('「나중에」면 인증을 보내지 않고 안내만 한다 · 기록도 남기지 않는다', (tester) async {
|
||||
SampleLocationConsent.shared
|
||||
..value = false
|
||||
..records.clear();
|
||||
final repo = await openPlace(tester);
|
||||
expect(find.byKey(const ValueKey('location-terms-body')), findsOneWidget);
|
||||
await tester.tap(find.text('나중에'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(repo.verified, 0);
|
||||
expect(SampleLocationConsent.shared.records, isEmpty);
|
||||
expect(find.text('위치기반 서비스 이용약관에 동의해야 위치 인증을 할 수 있어요.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('동의하면 기록한 뒤 인증으로 넘어가고, 다음부터는 묻지 않는다', (tester) async {
|
||||
SampleLocationConsent.shared
|
||||
..value = false
|
||||
..records.clear();
|
||||
final repo = await openPlace(tester);
|
||||
await tester.tap(find.text('동의'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(SampleLocationConsent.shared.records, [true]);
|
||||
expect(repo.verified, 1);
|
||||
});
|
||||
|
||||
testWidgets('이미 동의했으면 묻지 않는다', (tester) async {
|
||||
late BuildContext ctx;
|
||||
await pumpWide(tester, Builder(builder: (c) => (ctx = c, const SizedBox()).$2));
|
||||
final consent = SampleLocationConsent(true);
|
||||
expect(await ensureLocationConsent(ctx, consent), isTrue);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('location-terms-body')), findsNothing);
|
||||
expect(consent.records, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 지갑이 서버 값으로 바뀐 표본 — 생성 예치 · 수수료 뒤 헤더가 다시 맞는가.
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:fancake/features/my/settings_screen.dart';
|
||||
import 'package:fancake/features/my/support_screens.dart';
|
||||
import 'package:fancake/features/my/tier_screens.dart';
|
||||
import 'package:fancake/features/my/widgets/profile_card.dart';
|
||||
import 'package:fancake/features/quest/location_consent.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fancake/core/push/push_messaging.dart';
|
||||
@@ -466,6 +467,29 @@ void main() {
|
||||
expect(find.text('연결'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('위치기반 서비스 동의 — 켜면 동의 창을 거쳐 기록, 끄면 철회 행 (Q-E1 · R02)', (tester) async {
|
||||
SampleLocationConsent.shared
|
||||
..value = false
|
||||
..records.clear();
|
||||
await pumpWide(tester, SettingsScreen(repository: SampleMyRepository()));
|
||||
await tester.pump();
|
||||
final row = find.ancestor(of: find.text('위치기반 서비스 이용 동의'), matching: find.byType(Row));
|
||||
final toggle = find.descendant(of: row, matching: find.byType(Switch));
|
||||
await tester.scrollUntilVisible(toggle, 200);
|
||||
await tester.ensureVisible(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<Switch>(toggle).value, isFalse);
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('동의'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<Switch>(toggle).value, isTrue);
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<Switch>(toggle).value, isFalse);
|
||||
expect(SampleLocationConsent.shared.records, [true, false]);
|
||||
});
|
||||
|
||||
testWidgets('최신 버전이면 번호 대신 안내를 쓴다', (tester) async {
|
||||
await pumpWide(tester, SettingsScreen(repository: SampleMyRepository()));
|
||||
await tester.pump();
|
||||
@@ -547,26 +571,6 @@ void main() {
|
||||
});
|
||||
|
||||
group('SNS 연동 (F6 · 357:222740 외 3)', () {
|
||||
test('이메일 전체가 같을 때만 같은 계정으로 본다', () {
|
||||
// 앞부분만 같고 도메인이 다르면 다른 계정이다.
|
||||
expect(
|
||||
sameAccountIdentity('test123@naver.com', 'test123@gmail.com'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
sameAccountIdentity('test123@naver.com', 'other123@gmail.com'),
|
||||
isFalse,
|
||||
);
|
||||
// 대소문자·앞뒤 공백은 정규화한다.
|
||||
expect(
|
||||
sameAccountIdentity(' Test123@Naver.com ', 'test123@naver.com'),
|
||||
isTrue,
|
||||
);
|
||||
// 빈 값 · `@` 없는 값은 이메일이 아니다.
|
||||
expect(sameAccountIdentity('', ''), isFalse);
|
||||
expect(sameAccountIdentity('test123', 'test123'), isFalse);
|
||||
});
|
||||
|
||||
// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
|
||||
test('연동 대상은 확정 4종이고 X 는 없다', () {
|
||||
expect(linkableProviders, const [
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fancake/core/i18n/app_locale.dart';
|
||||
import 'package:fancake/core/theme/app_theme.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_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 소셜 로그인 진행 중(제공자 화면 복귀 뒤 서버 토큰 교환 포함) 화면 전체를 막고 진행 표시를 띄운다.
|
||||
void main() {
|
||||
for (final kind in AuthProviderKind.values.where(
|
||||
(k) => k != AuthProviderKind.email,
|
||||
)) {
|
||||
testWidgets('${kind.name} — 진행 중 로딩 · 중복 입력 막음 · 실패하면 다시 누를 수 있다', (
|
||||
tester,
|
||||
) async {
|
||||
var pending = Completer<AuthOutcome>();
|
||||
final provider = _Provider(kind, () => pending.future);
|
||||
final auth = AuthRepository(providers: [provider], releaseMode: true);
|
||||
addTearDown(auth.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
locale: const Locale('ko'),
|
||||
supportedLocales: appLocales,
|
||||
localizationsDelegates: const [
|
||||
RemoteL10nDelegate({}),
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
home: SimpleLoginScreen(auth: auth),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final button = find.text(providerButtonLabel(kind));
|
||||
expect(find.byType(BlockingProgress), findsNothing);
|
||||
await tester.tap(button);
|
||||
await tester.pump();
|
||||
expect(find.byType(BlockingProgress), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
await tester.tap(button, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
expect(provider.calls, 1);
|
||||
|
||||
pending.complete(const AuthFailed('E-AUTH-03', 'x'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(BlockingProgress), findsNothing);
|
||||
pending = Completer<AuthOutcome>();
|
||||
await tester.tap(button);
|
||||
await tester.pump();
|
||||
expect(provider.calls, 2);
|
||||
pending.complete(const AuthCancelled());
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final class _Provider implements SocialAuthProvider {
|
||||
_Provider(this.provider, this._signIn);
|
||||
|
||||
@override
|
||||
final AuthProviderKind provider;
|
||||
final Future<AuthOutcome> Function() _signIn;
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
bool get isLive => true;
|
||||
|
||||
@override
|
||||
List<String> get missingConfig => const [];
|
||||
|
||||
@override
|
||||
Future<AuthOutcome> signIn() {
|
||||
calls += 1;
|
||||
return _signIn();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:fancake/app.dart';
|
||||
import 'package:fancake/core/data/repositories.dart';
|
||||
import 'package:fancake/core/firebase/firebase_boot.dart';
|
||||
import 'package:fancake/core/i18n/app_locale.dart';
|
||||
import 'package:fancake/core/ui/app_dialog.dart';
|
||||
import 'package:fancake/features/planet/planet_data.dart' show countryName;
|
||||
import 'package:fancake/features/auth/auth.dart';
|
||||
import 'package:fancake/features/auth/auth_repository.dart';
|
||||
@@ -50,7 +51,68 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(auth.onboarding, OnboardingGate.required);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.text('개인정보 수집·이용 및 처리방침 동의 (필수)'), findsOneWidget);
|
||||
expect(find.text('개인정보 수집·이용 동의 (필수)'), findsOneWidget);
|
||||
// Q-E1 · R02 — 위치기반 약관은 가입 때 받지 않고, 개인정보처리방침은 체크 칸 없는 고지 · 보기 링크다.
|
||||
expect(find.textContaining('위치기반'), findsNothing);
|
||||
expect(find.text('개인정보처리방침'), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('privacy-policy-link')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('개인정보처리방침'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('개인정보처리방침 보기는 약관 문구가 아닌 처리방침 문구를 보여 준다', (tester) async {
|
||||
final (auth, _) = await boot(tester, needs: true);
|
||||
await auth.signIn(SocialProvider.google);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('privacy-policy-link')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final body = tester
|
||||
.widget<Text>(
|
||||
find.descendant(
|
||||
of: find.byType(AppDialog),
|
||||
matching: find.textContaining('임시 문구'),
|
||||
),
|
||||
)
|
||||
.data!;
|
||||
expect(body, contains('개인정보처리방침'));
|
||||
expect(body, isNot(contains('서비스 이용약관')));
|
||||
});
|
||||
|
||||
testWidgets('개인정보처리방침 고지는 iPhone 11 높이에서 스크롤 없이 하단 버튼 위에 보이고 체크 칸이 없다', (
|
||||
tester,
|
||||
) async {
|
||||
final (auth, _) = await boot(tester, needs: true);
|
||||
// iPhone 11 — 414x896pt · 상단 48 · 하단 34.
|
||||
tester.view.physicalSize = const Size(828, 1792);
|
||||
tester.view.padding = const FakeViewPadding(top: 96, bottom: 68);
|
||||
await auth.signIn(SocialProvider.google);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final notice = find.byKey(const ValueKey('privacy-policy-notice'));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: notice,
|
||||
matching: find.text('개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
final link = find.descendant(of: notice, matching: find.text('보기'));
|
||||
expect(link, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: notice, matching: find.byType(ImageIcon)),
|
||||
findsNothing,
|
||||
);
|
||||
expect(tester.getRect(link).bottom, lessThanOrEqualTo(896 - 34));
|
||||
expect(
|
||||
tester.getRect(notice).bottom,
|
||||
lessThanOrEqualTo(
|
||||
tester.getRect(find.widgetWithText(FilledButton, '이전')).top,
|
||||
),
|
||||
);
|
||||
await tester.tap(link);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('개인정보처리방침'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('약관 동의가 있는 계정은 바로 셸', (tester) async {
|
||||
@@ -86,8 +148,7 @@ void main() {
|
||||
// ② 약관 — 필수만 동의(마케팅 미동의).
|
||||
for (final label in [
|
||||
'서비스 이용약관 (필수)',
|
||||
'개인정보 수집·이용 및 처리방침 동의 (필수)',
|
||||
'위치기반 서비스 이용약관 (필수)',
|
||||
'개인정보 수집·이용 동의 (필수)',
|
||||
'만 14세 이상입니다. (필수)',
|
||||
]) {
|
||||
await tester.tap(find.text(label));
|
||||
@@ -95,7 +156,7 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('완료'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(repo.agreed, {'service', 'privacy', 'location', 'age'});
|
||||
expect(repo.agreed, {'service', 'privacy', 'age'});
|
||||
|
||||
// ③ 프로필
|
||||
await tester.enterText(find.byType(TextField).first, '우주팬');
|
||||
@@ -143,8 +204,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
for (final label in [
|
||||
'서비스 이용약관 (필수)',
|
||||
'개인정보 수집·이용 및 처리방침 동의 (필수)',
|
||||
'위치기반 서비스 이용약관 (필수)',
|
||||
'개인정보 수집·이용 동의 (필수)',
|
||||
'만 14세 이상입니다. (필수)',
|
||||
]) {
|
||||
await tester.tap(find.text(label));
|
||||
@@ -184,8 +244,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
for (final label in [
|
||||
'서비스 이용약관 (필수)',
|
||||
'개인정보 수집·이용 및 처리방침 동의 (필수)',
|
||||
'위치기반 서비스 이용약관 (필수)',
|
||||
'개인정보 수집·이용 동의 (필수)',
|
||||
'만 14세 이상입니다. (필수)',
|
||||
]) {
|
||||
await tester.tap(find.text(label));
|
||||
@@ -214,8 +273,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
for (final label in [
|
||||
'서비스 이용약관 (필수)',
|
||||
'개인정보 수집·이용 및 처리방침 동의 (필수)',
|
||||
'위치기반 서비스 이용약관 (필수)',
|
||||
'개인정보 수집·이용 동의 (필수)',
|
||||
'만 14세 이상입니다. (필수)',
|
||||
]) {
|
||||
await tester.tap(find.text(label));
|
||||
|
||||
@@ -114,6 +114,18 @@ void main() {
|
||||
isNot(contains('networkSecurityConfig')),
|
||||
);
|
||||
});
|
||||
|
||||
test('MainActivity 가 패키지 기본 task 에 있어 LINE 로그인 후 앱으로 돌아온다', () {
|
||||
// LINE SDK 는 lineauth:// 복귀 시 결과 화면을 NEW_TASK|CLEAR_TOP 으로 띄워 패키지
|
||||
// affinity 의 task 를 찾는다. MainActivity 의 taskAffinity 가 비어 있으면 새 task 가
|
||||
// 생겨 Chrome 맞춤 탭이 앱 위에 남는다(사용자가 X 를 눌러야 로그인이 끝난다).
|
||||
final manifest = _read('android/app/src/main/AndroidManifest.xml');
|
||||
final mainActivity = RegExp(
|
||||
r'<activity\s[^>]*android:name="\.MainActivity"[^>]*>',
|
||||
).firstMatch(manifest)?.group(0);
|
||||
expect(mainActivity, isNotNull);
|
||||
expect(mainActivity, isNot(contains('taskAffinity')));
|
||||
});
|
||||
});
|
||||
|
||||
group('서명 자산이 저장소에 올라가지 않는다', () {
|
||||
|
||||
Reference in New Issue
Block a user