generated from ttipo/project-base
feat(flutter): 새 메일 알림(FCM) · 최소 판 확인 · 설정 탭
앱 첫 판의 마지막 기능이다. 알림 글은 "새 메일 N통"뿐이고 화면은 확정 보드 App1Push · App1Shell 대로다. - firebase_core 5.1.0 · firebase_messaging 17.0.1 — FCM 토큰 API 가 deprecated 라 FID 로 등록한다. google-services.json 은 저장소에 두지 않고, 파일이 있을 때만 Gradle 플러그인을 건다(없으면 배포 판 빌드를 멈춘다). - 받은편지함 위 권하기 줄과 설정 › 새 메일 알림 — 휴대폰의 허락 창은 [알림 켜기]를 누를 때만, 켬 · 끔은 이 휴대폰에 둔다. - 등록 PUT · 끄기 DELETE · 봤음 unseen(앞에 있을 때만 · 30초 간격), 로그아웃과 세션 끝에는 FCM 등록을 푼다 (같은 휴대폰에 다른 직원이 로그인해도 앞 직원 알림이 남지 않게 — 풀기가 실패해도 다음 로그인은 다시 등록한다). - 앱이 앞에 있으면 알림 대신 목록을 바로 맞추고, 앞으로 오면 알림창의 알림을 지운다. 알림을 누르면 받은편지함으로 가고 쓰던 메일은 저장한다. - 앱 설정 GET /api/app-config 를 인증 줄 밖에서 먼저 묻고, 최소 판보다 낮으면 어느 화면에서든 업데이트 화면으로 간다(묻지 못하면 막지 않는다). - 설정 탭을 한 화면으로(내 정보 · 새 메일 알림 · 앱 정보 · 문의 · 로그아웃 확인 창), 알림 채널 "새 메일"을 Kotlin 에서 만든다. - 시험 932 → 1,056. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/constants/app_info_provider.dart';
|
||||
import 'package:arasystem_app/core/constants/screen_titles.dart';
|
||||
import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart';
|
||||
import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart';
|
||||
import 'package:arasystem_app/features/app_update/presentation/pages/app_update_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
|
||||
/// 시험 앱의 패키지 이름
|
||||
const String _package = 'kr.it.arasystem.app';
|
||||
|
||||
/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱(1.0.0)이 서버의 최소 판(2.0.0)보다 낮다
|
||||
void main() {
|
||||
late LoginHarness h;
|
||||
|
||||
/// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다(주소마다)
|
||||
final List<({Uri url, LaunchMode mode})> launched =
|
||||
<({Uri url, LaunchMode mode})>[];
|
||||
late bool Function(Uri url) opens;
|
||||
|
||||
Future<bool> launch(Uri url, LaunchMode mode) async {
|
||||
launched.add((url: url, mode: mode));
|
||||
return opens(url);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
h = LoginHarness();
|
||||
launched.clear();
|
||||
opens = (Uri _) => true;
|
||||
h.server.on(
|
||||
'GET',
|
||||
ApiPaths.appConfig,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '2.0.0'},
|
||||
'pushAvailable': true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> pump(WidgetTester tester, {String? packageName = _package}) =>
|
||||
h.pump(
|
||||
tester,
|
||||
overrides: <Override>[
|
||||
appVersionProvider.overrideWithValue('1.0.0'),
|
||||
appPackageNameProvider.overrideWithValue(packageName),
|
||||
appStoreLauncherProvider.overrideWithValue(
|
||||
AppStoreLauncher(launch: launch),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Finder storeButton() =>
|
||||
find.widgetWithText(FilledButton, AppUpdateTexts.openStore);
|
||||
|
||||
Finder snack(String text) =>
|
||||
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
|
||||
|
||||
testWidgets('제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄(연락처)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.supportContact('정보보안팀 02-1234-5678');
|
||||
await pump(tester);
|
||||
|
||||
expect(find.byType(AppUpdatePage), findsOneWidget);
|
||||
expect(find.text(ScreenTitles.update), findsOneWidget);
|
||||
expect(findCopy(AppUpdateTexts.message), findsOneWidget);
|
||||
expect(storeButton(), findsOneWidget);
|
||||
expect(find.text(AppUpdateTexts.currentVersion('1.0.0')), findsOneWidget);
|
||||
expect(
|
||||
findCopy('${AppUpdateTexts.supportQuestion}\n정보보안팀 02-1234-5678'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('연락처가 없으면 관리자에게 문의해 달라고 한다', (WidgetTester tester) async {
|
||||
h.supportContact(null);
|
||||
await pump(tester);
|
||||
|
||||
expect(findCopy(AppUpdateTexts.supportFallback), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('[Play 스토어 열기] — Play 스토어 앱부터, 열리지 않으면 웹 주소를 바깥 앱으로', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
opens = (Uri url) => url.scheme != 'market';
|
||||
await pump(tester);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, hasLength(2));
|
||||
expect(launched.first.url.toString(), 'market://details?id=$_package');
|
||||
expect(launched.first.mode, LaunchMode.externalNonBrowserApplication);
|
||||
expect(
|
||||
launched.last.url.toString(),
|
||||
'https://play.google.com/store/apps/details?id=$_package',
|
||||
);
|
||||
expect(launched.last.mode, LaunchMode.externalApplication);
|
||||
expect(snack(AppUpdateTexts.openStoreFailed), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Play 스토어 앱이 열리면 웹 주소는 열지 않는다', (WidgetTester tester) async {
|
||||
await pump(tester);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, hasLength(1));
|
||||
expect(launched.single.url.scheme, 'market');
|
||||
});
|
||||
|
||||
testWidgets('둘 다 열지 못하면 직접 찾아 업데이트해 달라고 알린다', (WidgetTester tester) async {
|
||||
opens = (Uri _) => false;
|
||||
await pump(tester);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, hasLength(2));
|
||||
expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('패키지 이름을 읽지 못했으면 열지 않고 알린다', (WidgetTester tester) async {
|
||||
await pump(tester, packageName: null);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, isEmpty);
|
||||
expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -8,10 +8,17 @@ import 'package:arasystem_app/core/api/error_lexicon.dart';
|
||||
import 'package:arasystem_app/core/api/error_notifier.dart';
|
||||
import 'package:arasystem_app/core/api/session_events.dart';
|
||||
import 'package:arasystem_app/core/api/token_refresher.dart';
|
||||
import 'package:arasystem_app/core/app_config/app_config_controller.dart';
|
||||
import 'package:arasystem_app/core/app_config/app_config_repository.dart';
|
||||
import 'package:arasystem_app/core/constants/login_texts.dart';
|
||||
import 'package:arasystem_app/core/constants/screen_titles.dart';
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/push/notification_system.dart';
|
||||
import 'package:arasystem_app/core/push/push_events.dart';
|
||||
import 'package:arasystem_app/core/push/push_messaging.dart';
|
||||
import 'package:arasystem_app/core/push/push_preferences.dart';
|
||||
import 'package:arasystem_app/core/push/push_registration.dart';
|
||||
import 'package:arasystem_app/core/push/push_state.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store.dart';
|
||||
import 'package:arasystem_app/core/utils/clock.dart';
|
||||
import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart';
|
||||
@@ -1850,6 +1857,15 @@ bool _allowedProviderValue(Object? value) =>
|
||||
value is MailChange ||
|
||||
value is MailToast ||
|
||||
value is Map<AppModuleId, int> ||
|
||||
// 앱 설정(최소 판 · 알림을 쓸 수 있는지)과 새 메일 알림 — FID · 토큰은 상태에 두지 않는다
|
||||
value is AppConfigState ||
|
||||
value is AppConfigRepository ||
|
||||
value is PushState ||
|
||||
value is PushEvent ||
|
||||
value is PushMessaging ||
|
||||
value is NotificationSystem ||
|
||||
value is PushPreferences ||
|
||||
value is PushRegistrationRepository ||
|
||||
value is bool;
|
||||
|
||||
/// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다
|
||||
|
||||
@@ -493,6 +493,78 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('새 메일 알림 (기기 등록)', () {
|
||||
Future<void> signedIn() async {
|
||||
await start(refreshToken: 'refresh-1');
|
||||
refreshGives(2);
|
||||
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
|
||||
expect(await settled(), isA<SignedIn>());
|
||||
await pumpEventsUntil(() => h.push.registered == 1, reason: '등록');
|
||||
}
|
||||
|
||||
test('세션이 끝나면(AUTH_002) 토큰을 지운 뒤 이 기기의 알림을 멈춘다', () async {
|
||||
await signedIn();
|
||||
h.clock.advance(const Duration(hours: 2));
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.fail(401, 'AUTH_001'),
|
||||
);
|
||||
h.server.on('GET', '/mailboxes', (_) => FakeResponse.ok(<Object?>[]));
|
||||
|
||||
await errorOf(container.read(dioProvider).get<Object?>('/mailboxes'));
|
||||
await pumpEventsUntil(() => h.push.unregistered == 1, reason: '알림 멈춤');
|
||||
|
||||
expect(current(), const SignedOut(reason: SignOutReason.expired));
|
||||
expect(h.push.hadSessionAtUnregister, isFalse);
|
||||
});
|
||||
|
||||
test('앱이 꺼진 사이 세션이 끝났으면(되살리기의 갱신 거절) 이 기기의 알림도 멈춘다', () async {
|
||||
await start(refreshToken: 'refresh-1');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.fail(401, 'AUTH_001'),
|
||||
);
|
||||
|
||||
expect(await settled(), const SignedOut());
|
||||
await pumpEventsUntil(() => h.push.unregistered == 1, reason: '알림 멈춤');
|
||||
expect(h.push.registered, 0);
|
||||
});
|
||||
|
||||
test('쓰던 세션에 제한이 생기면 멈추고(pause), 풀리면 다시 등록한다', () async {
|
||||
await signedIn();
|
||||
final SessionEventBus bus = container.read(sessionEventsProvider);
|
||||
|
||||
bus.emit(SessionRefreshed(sessionJson(restriction: 'passwordChange')));
|
||||
await pumpEventsUntil(
|
||||
() => restrictionNow() == SessionRestriction.passwordChange,
|
||||
reason: '제한 반영',
|
||||
);
|
||||
expect(h.push.paused, 1);
|
||||
expect(h.push.unregistered, 0);
|
||||
|
||||
bus.emit(SessionRefreshed(sessionJson()));
|
||||
await pumpEventsUntil(() => h.push.registered == 2, reason: '다시 등록');
|
||||
expect(h.push.paused, 1);
|
||||
});
|
||||
|
||||
test('제한된 채 로그인하면 멈출 것도 등록할 것도 없다', () async {
|
||||
await start();
|
||||
await settled();
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appLogin,
|
||||
(_) => signedInWith(1, restriction: 'twoFactorSetup'),
|
||||
);
|
||||
|
||||
await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
|
||||
|
||||
expect(h.push.registered, 0);
|
||||
expect(h.push.paused, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('제한 다시 맞추기 — 노티파이어의 갈래', () {
|
||||
Future<void> signedIn() async {
|
||||
await start(refreshToken: 'refresh-1');
|
||||
|
||||
@@ -1098,8 +1098,21 @@ void main() {
|
||||
|
||||
h.router.go(SettingsRoutes.home);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('로그아웃'));
|
||||
final Finder signOut = find.widgetWithText(OutlinedButton, '로그아웃');
|
||||
await tester.ensureVisible(signOut);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(signOut);
|
||||
await tester.pumpAndSettle();
|
||||
// 확인 창의 [로그아웃]
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.text('로그아웃'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
// 확인 창이 닫히는 동안 로그아웃한다 — 마지막 그림에서 떼어진 프로바이더는 그다음 차례에 정리된다
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(MailListPage), findsNothing);
|
||||
expect(h.container.exists(mailToastProvider), isFalse);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:arasystem_app/core/push/notification_system.dart';
|
||||
import 'package:arasystem_app/core/push/push_messaging.dart';
|
||||
import 'package:arasystem_app/core/push/push_preferences.dart';
|
||||
import 'package:arasystem_app/core/push/push_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/push_offer_line.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/login_fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
import '../../support/push_screen_harness.dart';
|
||||
|
||||
/// 받은편지함 위 새 메일 알림 권하기 줄 — 앱 보드 `App1Push` ①(줄) · ③(고른 뒤 알림)
|
||||
void main() {
|
||||
late PushScreenHarness h;
|
||||
|
||||
setUp(() {
|
||||
h = PushScreenHarness();
|
||||
// 처음 모습: 안드로이드 14 · 아직 묻지 않음 — 줄이 보인다
|
||||
h.messaging.current = PushPermission.notAsked;
|
||||
});
|
||||
|
||||
Finder line() => findCopy(PushTexts.offerMessage);
|
||||
|
||||
Finder enableButton() =>
|
||||
find.widgetWithText(TextButton, PushTexts.offerEnable);
|
||||
|
||||
Finder snack(String text) =>
|
||||
find.descendant(of: find.byType(SnackBar), matching: find.text(text));
|
||||
|
||||
testWidgets('보이는 때 — 받은편지함 위 막대 아래, 글 · [알림 켜기] · [닫기]', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final SemanticsHandle semantics = tester.ensureSemantics();
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsOneWidget);
|
||||
expect(enableButton(), findsOneWidget);
|
||||
expect(find.byTooltip(PushTexts.offerDismiss), findsOneWidget);
|
||||
expect(find.bySemanticsLabel(PushTexts.offerRegion), findsOneWidget);
|
||||
// 목록 위에 고정이다(목록과 함께 흐르지 않는다)
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(PushOfferLine)).dy,
|
||||
lessThanOrEqualTo(tester.getTopLeft(find.text('제목 m1')).dy),
|
||||
);
|
||||
expect(h.messaging.permissionRequests, 0, reason: '누르기 전에는 묻지 않는다');
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
group('보이지 않는 때', () {
|
||||
testWidgets('다른 메일함', (WidgetTester tester) async {
|
||||
await h.pump(tester, location: MailRoutes.mailbox(kClientsId));
|
||||
|
||||
expect(find.byType(PushOfferLine), findsNothing, reason: '받은편지함에만 둔다');
|
||||
});
|
||||
|
||||
testWidgets('안드로이드 12 이하(묻지 않고 처음부터 허용)', (WidgetTester tester) async {
|
||||
h.notifications.current = const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: true,
|
||||
sdkInt: 32,
|
||||
);
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('이미 허용', (WidgetTester tester) async {
|
||||
h.messaging.current = PushPermission.granted;
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('닫은 적 있음', (WidgetTester tester) async {
|
||||
h.prefs.values = const PushPreferenceValues(offerDismissed: true);
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('이 휴대폰에서 끔', (WidgetTester tester) async {
|
||||
h.prefs.values = const PushPreferenceValues(enabled: false);
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('서버가 보낼 수 없음', (WidgetTester tester) async {
|
||||
h.push.config = <String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
|
||||
'pushAvailable': false,
|
||||
};
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('메일을 고르는 동안', (WidgetTester tester) async {
|
||||
await h.pump(tester);
|
||||
expect(line(), findsOneWidget);
|
||||
|
||||
await tester.longPress(find.text('제목 m1'));
|
||||
await settle(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('[알림 켜기] → [허용] — "새 메일 알림을 켰습니다." · 서버에 등록하고 줄은 사라진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await h.pump(tester);
|
||||
expect(h.push.puts, isEmpty);
|
||||
|
||||
await tester.tap(enableButton());
|
||||
await settle(tester);
|
||||
|
||||
expect(h.messaging.permissionRequests, 1);
|
||||
expect(snack(PushTexts.enabled), findsOneWidget);
|
||||
expect(line(), findsNothing);
|
||||
expect(h.push.puts, hasLength(1));
|
||||
expect(h.prefs.values.enabled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('[알림 켜기] → [허용 안 함] — 나중에 켜는 곳을 알리고 줄은 사라진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.answer = PushPermission.denied;
|
||||
await h.pump(tester);
|
||||
|
||||
await tester.tap(enableButton());
|
||||
await settle(tester);
|
||||
|
||||
expect(snack(PushTexts.deniedLater), findsOneWidget);
|
||||
expect(line(), findsNothing);
|
||||
expect(h.push.puts, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('[알림 켜기] — 휴대폰이 묻지 않고 막혀 있으면 알림 줄에 [설정 열기]', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.answer = PushPermission.blocked;
|
||||
await h.pump(tester);
|
||||
|
||||
await tester.tap(enableButton());
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text(PushTexts.blockedNotice), findsOneWidget);
|
||||
expect(line(), findsNothing);
|
||||
await tester.tap(find.widgetWithText(TextButton, PushTexts.openSettings));
|
||||
await settle(tester);
|
||||
expect(h.notifications.settingsOpened, 1);
|
||||
});
|
||||
|
||||
testWidgets('[설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.answer = PushPermission.blocked;
|
||||
h.notifications.opens = false;
|
||||
await h.pump(tester);
|
||||
|
||||
await tester.tap(enableButton());
|
||||
await settle(tester);
|
||||
await tester.tap(find.widgetWithText(TextButton, PushTexts.openSettings));
|
||||
await settle(tester);
|
||||
|
||||
expect(snack(PushTexts.openSettingsFailed), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('[닫기] — 줄이 사라지고 이 휴대폰에서 다시 보이지 않는다(묻지도 않는다)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await h.pump(tester);
|
||||
|
||||
await tester.tap(find.byTooltip(PushTexts.offerDismiss));
|
||||
await settle(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
expect(h.prefs.values.offerDismissed, isTrue);
|
||||
expect(h.messaging.permissionRequests, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/push/notification_system.dart';
|
||||
import 'package:arasystem_app/core/push/push_messaging.dart';
|
||||
import 'package:arasystem_app/core/push/push_preferences.dart';
|
||||
import 'package:arasystem_app/core/push/push_texts.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
import '../../support/push_screen_harness.dart';
|
||||
|
||||
/// 설정 탭 — 앱 보드 `App1Shell`(설정 한 화면 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄)
|
||||
void main() {
|
||||
late PushScreenHarness h;
|
||||
|
||||
setUp(() => h = PushScreenHarness());
|
||||
|
||||
/// 문의 연락처를 답한다 — 부르지 않으면 가짜 서버가 404 로 답한다(연락처 없음)
|
||||
void supportContact(String? text) {
|
||||
h.server.on(
|
||||
'GET',
|
||||
ApiPaths.supportContact,
|
||||
(_) => FakeResponse.ok(<String, Object?>{'text': text}),
|
||||
);
|
||||
}
|
||||
|
||||
Finder dialog() => find.byType(AlertDialog);
|
||||
|
||||
Finder inDialog(String text) =>
|
||||
find.descendant(of: dialog(), matching: find.text(text));
|
||||
|
||||
Finder signOutButton() =>
|
||||
find.widgetWithText(OutlinedButton, SettingsTexts.signOut);
|
||||
|
||||
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
|
||||
await tester.ensureVisible(finder);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(finder);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
group('설정 한 화면', () {
|
||||
testWidgets('절 — 내 정보(이름 · 주소) · 알림 · 앱 정보(판) · 문의(연락처) · [로그아웃]', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
supportContact('정보보안팀 02-1234-5678');
|
||||
await h.pump(tester);
|
||||
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(h.app.location(), '/settings');
|
||||
for (final String title in <String>[
|
||||
SettingsTexts.account,
|
||||
SettingsTexts.notifications,
|
||||
SettingsTexts.about,
|
||||
SettingsTexts.support,
|
||||
]) {
|
||||
expect(find.text(title), findsOneWidget, reason: title);
|
||||
}
|
||||
expect(find.text('김아라'), findsOneWidget);
|
||||
expect(find.text(kTestEmail), findsOneWidget);
|
||||
expect(find.text(PushTexts.rowLabel), findsOneWidget);
|
||||
expect(find.text(SettingsTexts.appVersion), findsOneWidget);
|
||||
expect(find.text(kPushScreenAppVersion), findsOneWidget);
|
||||
expect(find.text(SettingsTexts.licenses), findsOneWidget);
|
||||
expect(
|
||||
findCopy('${SettingsTexts.supportLead}\n정보보안팀 02-1234-5678'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(signOutButton(), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('연락처가 없으면 관리자에게 문의해 달라고 한다', (WidgetTester tester) async {
|
||||
supportContact(null);
|
||||
await h.pump(tester);
|
||||
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(findCopy(SettingsTexts.supportFallback), findsOneWidget);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('[오픈소스 라이선스]는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async {
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tester.ensureVisible(find.text(SettingsTexts.licenses));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(SettingsTexts.licenses));
|
||||
// 라이선스 화면은 읽는 동안 둥근 표시가 돈다 — 화면 넘김만 기다린다
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(find.byType(LicensePage), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('[로그아웃] — 확인 창부터', () {
|
||||
testWidgets('처음 초점은 [취소] — [취소]면 머물고 로그아웃하지 않는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tapVisible(tester, signOutButton());
|
||||
|
||||
expect(inDialog(SettingsTexts.signOutTitle), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: dialog(),
|
||||
matching: findCopy(SettingsTexts.signOutMessage),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
final TextButton cancel = tester.widget<TextButton>(
|
||||
find.widgetWithText(TextButton, SettingsTexts.cancel),
|
||||
);
|
||||
expect(cancel.autofocus, isTrue);
|
||||
|
||||
await tester.tap(inDialog(SettingsTexts.cancel));
|
||||
await settle(tester);
|
||||
|
||||
expect(dialog(), findsNothing);
|
||||
expect(h.app.location(), '/settings');
|
||||
expect(h.server.received('POST', ApiPaths.appLogout), isEmpty);
|
||||
expect(h.messaging.unregisterCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('[로그아웃]을 고르면 로그인 화면 — 이 휴대폰의 알림 등록도 푼다(서버 줄은 서버가 지운다)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await h.pump(tester);
|
||||
expect(h.push.puts, hasLength(1));
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tapVisible(tester, signOutButton());
|
||||
await tester.tap(inDialog(SettingsTexts.signOut));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.byType(LoginPage), findsOneWidget);
|
||||
expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1));
|
||||
expect(h.messaging.unregisterCalls, 1);
|
||||
expect(h.push.deletes, isEmpty, reason: '로그아웃은 DELETE 를 부르지 않는다');
|
||||
expect(h.notifications.clears, greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
group('설정 › 새 메일 알림 줄', () {
|
||||
Finder switchFinder() => find.byType(Switch);
|
||||
|
||||
Switch switchOf(WidgetTester tester) =>
|
||||
tester.widget<Switch>(switchFinder());
|
||||
|
||||
Finder openSettingsButton() =>
|
||||
find.widgetWithText(TextButton, PushTexts.openSettings);
|
||||
|
||||
Finder snack(String text) =>
|
||||
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
|
||||
|
||||
void lifecycle(WidgetTester tester, List<AppLifecycleState> states) {
|
||||
for (final AppLifecycleState state in states) {
|
||||
tester.binding.handleAppLifecycleStateChanged(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// 휴대폰 설정에 다녀온다(앱이 뒤로 갔다 앞으로 온다)
|
||||
Future<void> visitPhoneSettings(WidgetTester tester) async {
|
||||
lifecycle(tester, const <AppLifecycleState>[
|
||||
AppLifecycleState.inactive,
|
||||
AppLifecycleState.hidden,
|
||||
AppLifecycleState.paused,
|
||||
]);
|
||||
await tester.pump();
|
||||
lifecycle(tester, const <AppLifecycleState>[
|
||||
AppLifecycleState.hidden,
|
||||
AppLifecycleState.inactive,
|
||||
AppLifecycleState.resumed,
|
||||
]);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
testWidgets('켜짐 — 스위치를 "새 메일 알림"으로 읽고 줄 아래 글을 설명으로 읽는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final SemanticsHandle semantics = tester.ensureSemantics();
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isTrue);
|
||||
expect(switchOf(tester).onChanged, isNotNull);
|
||||
expect(findCopy(PushTexts.rowDescription), findsOneWidget);
|
||||
expect(openSettingsButton(), findsNothing);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(
|
||||
tester.getSemantics(switchFinder()),
|
||||
isSemantics(
|
||||
label: PushTexts.rowLabel,
|
||||
hint: PushTexts.rowDescription,
|
||||
hasToggledState: true,
|
||||
isToggled: true,
|
||||
hasEnabledState: true,
|
||||
isEnabled: true,
|
||||
),
|
||||
);
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('끄기 — 끄는 중(둥근 표시)에는 다시 받지 않고, 서버 등록을 지운 뒤 꺼진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final SemanticsHandle semantics = tester.ensureSemantics();
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
h.push.deleteGate = Completer<void>();
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(h.push.deletes, hasLength(1));
|
||||
expect(switchOf(tester).value, isFalse, reason: '바꿀 값 쪽에 둔다');
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(find.bySemanticsLabel(PushTexts.turningOff), findsOneWidget);
|
||||
|
||||
// 끄는 중에 다시 눌러도 겹쳐 보내지 않는다
|
||||
await tester.tap(switchFinder());
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(h.push.deletes, hasLength(1));
|
||||
|
||||
h.push.deleteGate!.complete();
|
||||
await settle(tester);
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(h.prefs.values.enabled, isFalse);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('켜기(이미 허용) — 켜는 중을 보이고, 서버에 등록한 뒤 켜진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final SemanticsHandle semantics = tester.ensureSemantics();
|
||||
h.prefs.values = const PushPreferenceValues(enabled: false);
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(h.push.puts, isEmpty, reason: '끈 휴대폰은 등록하지 않는다');
|
||||
h.push.putGate = Completer<void>();
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(h.push.puts, hasLength(1));
|
||||
expect(switchOf(tester).value, isTrue, reason: '바꿀 값 쪽에 둔다');
|
||||
expect(find.bySemanticsLabel(PushTexts.turningOn), findsOneWidget);
|
||||
|
||||
h.push.putGate!.complete();
|
||||
await settle(tester);
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(switchOf(tester).value, isTrue);
|
||||
expect(h.prefs.values.enabled, isTrue);
|
||||
expect(h.push.puts.single.body['fid'], 'fid-1');
|
||||
expect(h.messaging.permissionRequests, 0, reason: '이미 허용이면 묻지 않는다');
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('켜기가 서버에서 실패하면 꺼진 채로 돌아가고 그 코드의 사전 문구를 알린다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.prefs.values = const PushPreferenceValues(enabled: false);
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
h.push.putFailure = FakeResponse.fail(500, 'SYS_001');
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await settle(tester);
|
||||
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(h.prefs.values.enabled, isFalse);
|
||||
expect(h.notifier.shownCodes, <String>['SYS_001']);
|
||||
});
|
||||
|
||||
testWidgets('허락 전(처음 값 켬)에서 켜고 [허용]했는데 서버에서 실패하면 꺼진 채로 돌아간다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.current = PushPermission.notAsked;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
expect(h.prefs.values.enabled, isTrue, reason: '처음 값');
|
||||
h.push.putFailure = FakeResponse.fail(503, 'MAIL_001');
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await settle(tester);
|
||||
|
||||
expect(h.messaging.permissionRequests, 1);
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNotNull);
|
||||
expect(
|
||||
findCopy(PushTexts.rowDescription),
|
||||
findsOneWidget,
|
||||
reason: '허락은 받았다',
|
||||
);
|
||||
expect(h.prefs.values.enabled, isFalse);
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
});
|
||||
|
||||
testWidgets('허락 전 — 켜면 휴대폰이 묻고, [허용]이면 켜진다', (WidgetTester tester) async {
|
||||
h.messaging.current = PushPermission.notAsked;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNotNull);
|
||||
expect(findCopy(PushTexts.rowNotAsked), findsOneWidget);
|
||||
expect(h.push.puts, isEmpty);
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await settle(tester);
|
||||
|
||||
expect(h.messaging.permissionRequests, 1);
|
||||
expect(switchOf(tester).value, isTrue);
|
||||
expect(findCopy(PushTexts.rowDescription), findsOneWidget);
|
||||
expect(h.push.puts, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('허락 전 — [허용 안 함]이면 꺼진 채 허락 전 글 그대로, 다시 켜면 또 묻는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging
|
||||
..current = PushPermission.notAsked
|
||||
..answer = PushPermission.denied;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await settle(tester);
|
||||
|
||||
expect(h.messaging.permissionRequests, 1);
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(findCopy(PushTexts.rowNotAsked), findsOneWidget);
|
||||
expect(h.push.puts, isEmpty);
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await settle(tester);
|
||||
expect(h.messaging.permissionRequests, 2);
|
||||
});
|
||||
|
||||
testWidgets('휴대폰에서 막힘 — 스위치는 누를 수 없고 [설정 열기], 다녀와서 허용됐으면 켜진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.current = PushPermission.blocked;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNull);
|
||||
expect(findCopy(PushTexts.rowBlocked), findsOneWidget);
|
||||
expect(openSettingsButton(), findsOneWidget);
|
||||
expect(h.push.puts, isEmpty);
|
||||
|
||||
await tester.tap(openSettingsButton());
|
||||
await settle(tester);
|
||||
expect(h.notifications.settingsOpened, 1);
|
||||
expect(snack(PushTexts.openSettingsFailed), findsNothing);
|
||||
|
||||
h.messaging.current = PushPermission.granted;
|
||||
await visitPhoneSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isTrue);
|
||||
expect(openSettingsButton(), findsNothing);
|
||||
expect(h.push.puts, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('[설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.current = PushPermission.blocked;
|
||||
h.notifications.opens = false;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tester.tap(openSettingsButton());
|
||||
await settle(tester);
|
||||
|
||||
expect(snack(PushTexts.openSettingsFailed), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('새 메일 알림만 꺼짐 — 스위치는 누를 수 없고 [설정 열기], 다녀와서 켜졌으면 켜진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.notifications.current = const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: false,
|
||||
sdkInt: 34,
|
||||
);
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNull);
|
||||
expect(findCopy(PushTexts.rowChannelOff), findsOneWidget);
|
||||
expect(openSettingsButton(), findsOneWidget);
|
||||
expect(h.push.puts, isEmpty);
|
||||
|
||||
h.notifications.current = const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: true,
|
||||
sdkInt: 34,
|
||||
);
|
||||
await visitPhoneSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isTrue);
|
||||
expect(findCopy(PushTexts.rowDescription), findsOneWidget);
|
||||
expect(h.push.puts, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('서버가 보낼 수 없으면 — 스위치는 누를 수 없고 쓸 수 없다는 글, 등록하지 않는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.push.config = <String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
|
||||
'pushAvailable': false,
|
||||
};
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNull);
|
||||
expect(findCopy(PushTexts.rowUnavailable), findsOneWidget);
|
||||
expect(openSettingsButton(), findsNothing);
|
||||
// 앱 설정은 세션 되살리기(미리 갱신)를 기다리지 않고 먼저 온다 — 등록하지 않는다. 앞으로 와도 그대로
|
||||
expect(h.push.puts, isEmpty);
|
||||
await visitPhoneSettings(tester);
|
||||
expect(h.push.puts, isEmpty);
|
||||
expect(h.push.deletes, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('받을 수 없는 휴대폰이면 — 스위치는 누를 수 없고 받을 수 없다는 글', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.available = false;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNull);
|
||||
expect(findCopy(PushTexts.rowUnsupported), findsOneWidget);
|
||||
expect(h.messaging.registerCalls, 0);
|
||||
expect(h.push.puts, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user